Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
6e8a6ec
feat: expose a low-level API for the Juju hook commands.
tonyandrewmeyer Aug 28, 2025
7a7f64d
Add basic unit test coverage.
tonyandrewmeyer Aug 28, 2025
baeeb3b
Add a start at integration tests.
tonyandrewmeyer Aug 28, 2025
e489165
Fix linting.
tonyandrewmeyer Aug 28, 2025
9439cb8
Shove the TemporaryDirectory into git.
tonyandrewmeyer Aug 28, 2025
d3b01a8
Fix unit tests.
tonyandrewmeyer Aug 28, 2025
5e10803
Fix docs build.
tonyandrewmeyer Aug 28, 2025
b9471c5
Fix harness.
tonyandrewmeyer Aug 28, 2025
c4fbe58
Add to integration tests list.
tonyandrewmeyer Aug 28, 2025
f805fb5
Remove unnecessary change.
tonyandrewmeyer Aug 28, 2025
5d0fa86
Remove unnecessary change.
tonyandrewmeyer Aug 28, 2025
b09a0a1
Update ops/hookcmds.py
tonyandrewmeyer Sep 3, 2025
aff27a0
Update ops/hookcmds.py
tonyandrewmeyer Sep 4, 2025
e39ad24
Remove unnecessary quotation marks.
tonyandrewmeyer Sep 3, 2025
04aca1e
Rename dummy resource.
tonyandrewmeyer Sep 3, 2025
9a4526f
Rename fixture, per review.
tonyandrewmeyer Sep 3, 2025
1254ea9
Don't expose the yaml module.
tonyandrewmeyer Sep 3, 2025
679da04
Simplify the error class name.
tonyandrewmeyer Sep 3, 2025
dd95cd2
Use Iterable[str] not list[str] for args, unify Port.
tonyandrewmeyer Sep 4, 2025
0e19fbc
Use TypeError for bad arg combo.
tonyandrewmeyer Sep 4, 2025
7abce36
Make level a keyword only literal.
tonyandrewmeyer Sep 4, 2025
cb56f94
Make 'now' keyword only.
tonyandrewmeyer Sep 4, 2025
0aea69e
Add comment.
tonyandrewmeyer Sep 4, 2025
51d41ff
Drop --file from relation_set.
tonyandrewmeyer Sep 4, 2025
c6861f3
Use extend for consistency.
tonyandrewmeyer Sep 4, 2025
da280f5
Add args to docs.
tonyandrewmeyer Sep 4, 2025
2bdc746
Make endpoints kwonly.
tonyandrewmeyer Sep 4, 2025
9279870
Make relation_id kwonly.
tonyandrewmeyer Sep 4, 2025
33e5af2
Add overloads for open/close port.
tonyandrewmeyer Sep 4, 2025
337452f
Consolidate cloudspec/cloudcredential.
tonyandrewmeyer Sep 4, 2025
adf3807
Simplify the _run() method.
tonyandrewmeyer Sep 4, 2025
fbfa117
Add _from_dict methods, per review.
tonyandrewmeyer Sep 4, 2025
aac9ce3
Handle older Pythons with date conversion.
tonyandrewmeyer Sep 4, 2025
5b6a053
Fix unified Port class.
tonyandrewmeyer Sep 4, 2025
aba3052
Go back to duplicating Port.
tonyandrewmeyer Sep 10, 2025
2c1f6a2
Use ValueError rather than an Assert. Drop --all from config-get.
tonyandrewmeyer Sep 10, 2025
97c299a
Simplify the storage_get signature.
tonyandrewmeyer Sep 10, 2025
dfa4207
Clean up more signatures.
tonyandrewmeyer Sep 10, 2025
ec50c75
Update tests to reflect recent changes.
tonyandrewmeyer Sep 10, 2025
6ce9b0e
Small fixes.
tonyandrewmeyer Sep 18, 2025
2124449
Update integration tests.
tonyandrewmeyer Sep 18, 2025
592a511
Remove the integration tests from this branch.
tonyandrewmeyer Sep 23, 2025
4e38491
Move hookcmds to a package.
tonyandrewmeyer Sep 23, 2025
bd9af43
Fix secret-add.
tonyandrewmeyer Sep 23, 2025
69e3b10
Merge origin/main.
tonyandrewmeyer Sep 23, 2025
0af9f33
Add comments outlining where we deviate from Juju.
tonyandrewmeyer Sep 25, 2025
3509476
Add more overloads to indicate that you can't use peek and refresh at…
tonyandrewmeyer Sep 25, 2025
328a75e
Clarify the relation-set behaviour, per review.
tonyandrewmeyer Sep 25, 2025
599e4bf
Add links to Juju docs.
tonyandrewmeyer Sep 25, 2025
d03d102
Don't support '-' as a relation-get key.
tonyandrewmeyer Sep 25, 2025
a7b89be
Expand unit test coverage.
tonyandrewmeyer Sep 26, 2025
fa98e99
Fix pyright + parametrize issues.
tonyandrewmeyer Sep 26, 2025
46f47f7
Adjustments from review.
tonyandrewmeyer Sep 27, 2025
6d5ec89
Hook up the reference docs.
tonyandrewmeyer Sep 27, 2025
49294f3
Drop enum.
tonyandrewmeyer Sep 28, 2025
5b60dc2
Add temporary comments.
tonyandrewmeyer Sep 28, 2025
199e98b
Help Sphinx understand which class the type is.
tonyandrewmeyer Sep 29, 2025
fe49cb4
Change the literal back to 'application', per discussion. Also add a …
tonyandrewmeyer Sep 29, 2025
16cc50e
Explicitly list all the classes and methods, except the dupes.
tonyandrewmeyer Sep 29, 2025
950ed25
Ensure that the doc list is always complete.
tonyandrewmeyer Sep 29, 2025
74669d6
Without automodule, we have to write the top-level docs in the ReST doc.
tonyandrewmeyer Sep 29, 2025
48a5076
Now that we're duplicating the intro, we can use inter-sphinx links.
tonyandrewmeyer Oct 1, 2025
a8c656b
Add markdown for the Juju links.
tonyandrewmeyer Oct 1, 2025
03fa8c3
Fix intersphinx links.
tonyandrewmeyer Oct 1, 2025
5058640
Revert previous decision and duplicate CloudSpec and CloudCredential.
tonyandrewmeyer Oct 1, 2025
1b6640f
Mark up links.
tonyandrewmeyer Oct 1, 2025
868de2d
Remove unnnecessary change.
tonyandrewmeyer Oct 1, 2025
62e6d47
Apply suggestions from code review
tonyandrewmeyer Oct 2, 2025
e67490a
Per review, leave the race condition in place, for higher level code …
tonyandrewmeyer Oct 5, 2025
276b0a0
Handle messages and logs that start with a hypen.
tonyandrewmeyer Oct 5, 2025
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 docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ pebble
ops-testing
ops-testing-harness
ops-tracing
ops-hookcmds
```

6 changes: 6 additions & 0 deletions docs/reference/ops-hookcmds.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.. _ops_hookcmds:

`ops.hookcmds`
==============

.. automodule:: ops.hookcmds
8 changes: 4 additions & 4 deletions ops/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@
'StoredSet',
'StoredState',
'StoredStateData',
# From hookcmds.py
'StatusName',
# From jujucontext.py
'JujuContext',
# From jujuversion.py
Expand Down Expand Up @@ -163,8 +165,8 @@
'Network',
'NetworkInterface',
'OpenedPort',
'Port',
'Pod',
'Port',
'Relation',
'RelationData',
'RelationDataAccessError',
Expand All @@ -180,7 +182,6 @@
'SecretRotate',
'ServiceInfoMapping',
'StatusBase',
'StatusName',
'Storage',
'StorageMapping',
'TooManyRelatedAppsError',
Expand Down Expand Up @@ -285,8 +286,8 @@
StoredStateData,
)

from .hookcmds import StatusName
from .jujucontext import JujuContext

from .jujuversion import JujuVersion

from .model import (
Expand Down Expand Up @@ -331,7 +332,6 @@
SecretRotate,
ServiceInfoMapping,
StatusBase,
StatusName,
Storage,
StorageMapping,
TooManyRelatedAppsError,
Expand Down
6 changes: 3 additions & 3 deletions ops/_private/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
from .. import charm, framework, model, pebble, storage
from ..charm import CharmBase, CharmMeta, RelationRole
from ..jujucontext import JujuContext
from ..model import Container, RelationNotFoundError, StatusName, _NetworkDict
from ..model import Container, RelationNotFoundError, _NetworkDict, _StatusName
from ..pebble import ExecProcess
from . import yaml

Expand Down Expand Up @@ -95,7 +95,7 @@ class State:
_RawStatus = TypedDict(
'_RawStatus',
{
'status': StatusName,
'status': _StatusName,
'message': str,
},
)
Expand Down Expand Up @@ -2534,7 +2534,7 @@ def status_get(self, *, is_app: bool = False):
else:
return self._unit_status

def status_set(self, status: StatusName, message: str = '', *, is_app: bool = False):
def status_set(self, status: _StatusName, message: str = '', *, is_app: bool = False):
if status in [model.ErrorStatus.name, model.UnknownStatus.name]:
raise model.ModelError(
f'ERROR invalid status "{status}", expected one of'
Expand Down
138 changes: 138 additions & 0 deletions ops/hookcmds/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Copyright 2025 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Low-level access to the Juju hook commands.

Charm authors should use the :class:`ops.Model` (via ``self.model``) rather than
directly running the hook commands, where possible. This module is primarily
provided to help with developing charming alternatives to the Ops framework.

Note: ``hookcmds`` is not covered by the semver policy that applies to the rest
of Ops. We will do our best to avoid breaking changes, but we reserve the right
to make breaking changes within this package if necessary, within the Ops 3.x
series.

All methods are 1:1 mapping to Juju hook commands. This is a *low-level* API,
available for charm use, but expected to be used via higher-level wrappers.

See `Juju | Hook commands <https://documentation.ubuntu.com/juju/3.6/reference/hook-command/>`_
and `Juju | Hook command list <https://documentation.ubuntu.com/juju/3.6/reference/hook-command/list-of-hook-commands/>`_
for a list of all Juju hook commands.
"""

from __future__ import annotations

from ._action import action_fail, action_get, action_log, action_set
from ._other import (
app_version_set,
config_get,
credential_get,
goal_state,
is_leader,
juju_log,
juju_reboot,
network_get,
resource_get,
)
from ._port import close_port, open_port, opened_ports
from ._relation import relation_get, relation_ids, relation_list, relation_model_get, relation_set
from ._secret import (
secret_add,
secret_get,
secret_grant,
secret_ids,
secret_info_get,
secret_remove,
secret_revoke,
secret_set,
)
from ._state import state_delete, state_get, state_set
from ._status import status_get, status_set
from ._storage import storage_add, storage_get, storage_list
from ._types import (
Address,
AppStatus,
BindAddress,
CloudCredential,
CloudSpec,
Goal,
GoalState,
Network,
Port,
RelationModel,
SecretInfo,
SecretRotate,
SettableStatusName,
StatusName,
Storage,
UnitStatus,
)
from ._utils import Error

__all__ = [
'Address',
'AppStatus',
'BindAddress',
'CloudCredential',
'CloudSpec',
'Error',
'Goal',
'GoalState',
'Network',
'Port',
'RelationModel',
'SecretInfo',
'SecretRotate',
'SettableStatusName',
'StatusName',
'Storage',
'UnitStatus',
'action_fail',
'action_get',
'action_log',
'action_set',
'app_version_set',
'close_port',
'config_get',
'credential_get',
'goal_state',
'is_leader',
'juju_log',
'juju_reboot',
'network_get',
'open_port',
'opened_ports',
'relation_get',
'relation_ids',
'relation_list',
'relation_model_get',
'relation_set',
'resource_get',
'secret_add',
'secret_get',
'secret_grant',
'secret_ids',
'secret_info_get',
'secret_remove',
'secret_revoke',
'secret_set',
'state_delete',
'state_get',
'state_set',
'status_get',
'status_set',
'storage_add',
'storage_get',
'storage_list',
]
151 changes: 151 additions & 0 deletions ops/hookcmds/_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Copyright 2025 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import json
from typing import (
Any,
Mapping,
MutableMapping,
cast,
overload,
)

from ._utils import run


def format_result_dict(
input: Mapping[str, Any],
parent_key: str | None = None,
output: dict[str, str] | None = None,
) -> dict[str, str]:
"""Turn a nested dictionary into a flattened dictionary, using '.' as a key separator.
This is used to allow nested dictionaries to be translated into the dotted
format required by the Juju `action-set` hook command in order to set nested
data on an action.
Example::
>>> test_dict = {'a': {'b': 1, 'c': 2}}
>>> format_result_dict(test_dict)
{'a.b': 1, 'a.c': 2}
Arguments:
input: The dictionary to flatten
parent_key: The string to prepend to dictionary's keys
output: The current dictionary to be returned, which may or may not yet
be completely flat
Returns:
A flattened dictionary
Raises:
ValueError: if the dict is passed with a mix of dotted/non-dotted keys
that expand out to result in duplicate keys. For example:
``{'a': {'b': 1}, 'a.b': 2}``.
"""
Comment on lines +29 to +59
Copy link
Contributor

@dimaqq dimaqq Oct 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not an exported function, so I wonder if a large, detailed doc string is called for.

Minor: it would be nice to document what happens to other types of values, e.g.:

{'foo': 2**64}, effectively coerced to a string at exec() time, using the Python rules
...: [1,2,3], likewise
{'foo.0': 42, 'foo.1': 42}, Juju will later reassemble that to {foo: {0: 42, 1: 42}}
{'return-code': "whatever"}, lost, Juju overwrites with return-code: 0

That being said, I'm not sure where to document this.

output_: dict[str, str] = output or {}

for key, value in input.items():
if parent_key:
key = f'{parent_key}.{key}'

if isinstance(value, MutableMapping):
value = cast('dict[str, Any]', value)
output_ = format_result_dict(value, key, output_)
elif key in output_:
raise ValueError(
f"duplicate key detected in dictionary passed to 'action-set': {key!r}"
)
else:
output_[key] = value

return output_


def action_fail(message: str | None = None):
"""Set action fail status with message.
For more details, see:
`Juju | Hook commands | action-fail <https://documentation.ubuntu.com/juju/3.6/reference/hook-command/list-of-hook-commands/action-fail/>`_
Args:
message: the failure error message. Juju will provide a default message
if one is not provided.
"""
args: list[str] = []
if message is not None:
# The '--' allows messages that start with a hyphen.
args.extend(['--', message])
run('action-fail', *args)


@overload
def action_get() -> dict[str, Any]: ...
@overload
def action_get(key: str) -> str: ...
def action_get(key: str | None = None) -> dict[str, Any] | str:
"""Get action parameters.
``action_get`` returns the value of the parameter at the given key. If a
dotted key (for example foo.bar) is passed, ``action_get`` will recurse into
the parameter map as needed.
For more details, see:
`Juju | Hook commands | action-get <https://documentation.ubuntu.com/juju/3.6/reference/hook-command/list-of-hook-commands/action-get/>`_
Args:
key: The key of the action parameter to retrieve. If not provided, all
parameters will be returned.
"""
args = ['--format=json']
if key is not None:
args.append(key)
stdout = run('action-get', *args)
result = (
cast('dict[str, Any]', json.loads(stdout))
if key is None
else cast('str', json.loads(stdout))
)
return result


def action_log(message: str):
"""Record a progress message for the current action.
For more details, see:
`Juju | Hook commands | action-log <https://documentation.ubuntu.com/juju/3.6/reference/hook-command/list-of-hook-commands/action-log/>`_
Args:
message: The progress message to provide to the Juju user.
"""
# The '--' allows messages that start with a hyphen.
run('action-log', '--', message)


def action_set(results: Mapping[str, Any]):
"""Set action results.
For more details, see:
`Juju | Hook commands | action-set <https://documentation.ubuntu.com/juju/3.6/reference/hook-command/list-of-hook-commands/action-set/>`_
Args:
results: The results map of the action, provided to the Juju user.
"""
# The Juju action-set hook tool cannot interpret nested dicts, so we use a
# helper to flatten out any nested dict structures into a dotted notation.
flat_results = format_result_dict(results)
run('action-set', *[f'{k}={v}' for k, v in flat_results.items()])
Loading