Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: Document and validate settable status values in _ModelBackend.set_status #1354

Merged

Conversation

james-garner-canonical
Copy link
Contributor

Document the status values that are valid arguments for _ModelBackend.status_set, as requested in #1343, via type annotation. The status argument was previously type hinted only as str. However, this argument can really only be one of four valid statuses that Juju's status-set will accept ('active', 'blocked', 'maintenance', 'waiting'). This new type information is propagated to StatusBase and relevant tests, including _TestingModelBackend.

Since the arguments to status_set are already partially validated, (is_app being a bool in status_set, status and message being strings in Application.status and Unit.status), and and the valid values for status are now encoded and associated with this method, we can check that status is valid in status_set and raise a ModelError immediately if not, rather than catching status-set's exit code and only then raising a ModelError.

Tests are updated to account for this (test_collect_status_priority split into test_collect_status_priority_valid and test_collect_status_priority_invalid since status-set isn't called in the invalid case, and test_local_set_invalid_status is updated to account for this too).

Since is_app and status are both validated in status_set, it makes sense to also validate the type of message here, removing redundant checks in Application.status and cleaning up a fixme in Unit.status. A test is added to cover this (test_status_set_message_not_str_raises).

Finally, fix a couple of broken unit tests that interact with StatusBase and status_set (test_base_status_instance_raises and test_status_set_is_app_not_bool_raises respectively).

Previously was catching AttributeError, which must have been raised
historically for missing `name` attribute on StatusBase subclasses.
This, combined with a `type: ignore` directive concealed that the
`register_status` method does not exist -- the attempt to access it
raises an AttributeError. The correct method is `register`, which
raises a TypeError.
A `type: ignore` directive hid that `status_set` was being called with
the wrong argument types. The intention was to test that an error is
raised on calling with a non-bool argument for `is_app`, however the
status argument should be a string, and was instead a `StatusBase`
class. The test would have still correctly errored out on the `is_app`
argument before this error had an effect at testing time.
_ModelBackend.status_set's status argument was previously type hinted
only as str. However, this argument can really only be one of four valid
statuses that juju's set-status will accept. This commit documents this,
and adjusts the definition of StatusBase to also type hint the six
acceptable status names, as well as using these type hints across code
that interacts with StatusBase and status_set.
Since we know the valid arguments for status, and expose them in type
hints, and since ops already validates arguments here to some extent
(is_app being a bool in status_set, status and message being strings in
Application.status and Unit.status), let's check that status is valid
in status_set and raise a ModelError immediately if not, rather than
catching status-set's exit code and only then raising a ModelError.
Since _ModelBackend.set_status validates its status and is_app
arguments, let's also validate the type of message, removing redundant
checks from Application.status and cleaning up a fixme in Unit.status.
Also add a test to cover this, analogous to the existing test for the
type of is_app.
@james-garner-canonical james-garner-canonical changed the title Document and validate settable status values in _ModelBackend.set_status chore: Document and validate settable status values in _ModelBackend.set_status Sep 3, 2024
@dimaqq
Copy link
Contributor

dimaqq commented Sep 3, 2024

  /home/runner/work/operator/operator/src/charm.py:119:33 - error: Argument of type "str" cannot be assigned to parameter "name" of type "_StatusName" in function "from_name"
    Type "str" is not assignable to type "_StatusName"
      "str" is not assignable to type "Literal['active']"
      "str" is not assignable to type "Literal['blocked']"
      "str" is not assignable to type "Literal['maintenance']"
      "str" is not assignable to type "Literal['waiting']"
      "str" is not assignable to type "Literal['error']"
      "str" is not assignable to type "Literal['unknown']" (reportArgumentType)
1 error, 0 warnings, 0 informations 

Given that it's an observability job that fails, I suspect that this change is a breaking change subject to how charmers use our code today.

Please check.

P.S. I recommend amazing https://github.com/tonyandrewmeyer/charm-analysis to try new code across some 120~160 charms.

@james-garner-canonical
Copy link
Contributor Author

This PR breaks a type checking test in canonical/prometheus-k8s-operator where StatusBase.from_name(name, message) is called. The error is because name is typed as str in this charm, while this PR changes the type of the name argument in from_name from str to Literal['active', 'blocked', 'maintenance', 'waiting', 'error', 'unknown'].

Since StatusBase.from_name is part of the public api of ops, this isn't good. On the other hand, from_name does currently raises a KeyError at runtime if called with a name that isn't one of those six.

Solutions here include:

  1. change the type of the name argument of from_name back to str, and cast/etc internally (it's not our users' problem)
  2. require users to either more strongly type their arguments, use a cast, or use a type: ignore[asignment]
    2a. make it easier to use the type Literal['active', 'blocked', 'maintenance', 'waiting', 'error', 'unknown'] externally (e.g. expose a model.StatusName type alias in the public api)
    2b. submit a PR to canonical/prometheus-k8s-operator to help with this

I'd lean in favour of (1), and will look into modifying this PR to avoid changes to the type signature of the public api at this point.

Since StatusBase.from_name is part of the public API, and is currently
used in several charms, changing the type of the name argument from str
to _StatusName will break users' tests. Furthermore, satisfying the type
checker as to the contents of the strings used may be painful for users,
leading to a proliferation of casts or `type: ignore`s. Therefore, the
type or the name argument has been reverted to str.
@james-garner-canonical
Copy link
Contributor Author

P.S. I recommend amazing https://github.com/tonyandrewmeyer/charm-analysis to try new code across some 120~160 charms.

Thanks, I can see that StatusBase.from_name is used in three more charms (see below), as well as in Ben's test-charms repository. I've reverted the signature of this method. I don't think any other changes affect the public API.

Charms that use StatusBase.from_name:

prometheus-k8s-operator/src/charm.py
sunbeam-charms/ops-sunbeam/ops_sunbeam/compound_status.py
jenkins-k8s-operator/src/charm.py
loki-k8s-operator/src/charm.py
test-charms/statustest/src/statuspool.py

@dimaqq
Copy link
Contributor

dimaqq commented Sep 3, 2024

Looking at loki example, what they do:

class CompositeStatus(TypedDict):
    """Per-component status holder."""

    # These are going to go into stored state, so we must use marshallable objects.
    # They are passed to StatusBase.from_name().
    k8s_patch: Tuple[str, str]
    config: Tuple[str, str]
    rules: Tuple[str, str]
    retention: Tuple[str, str]

...


    def _on_k8s_patch_failed(self, event: K8sResourcePatchFailedEvent):
        self._stored.status["k8s_patch"] = to_tuple(BlockedStatus(cast(str, event.message)))

...

def to_status(tpl: Tuple[str, str]) -> StatusBase:
    """Convert a tuple to a StatusBase, so it could be used natively with ops."""
    name, message = tpl
    return StatusBase.from_name(name, message)

...

    def _on_collect_unit_status(self, event: CollectStatusEvent):
        # "Pull" statuses
        # TODO refactor _configure to turn the "rules" status into a "pull" status.

        # "Push" statuses
        for status in self._stored.status.values():
            event.add_status(to_status(status))

Arguably that code could be refactored, though it's unclear exactly how.

One option would be to change str to a union of literals...

Another option would be observe that _stored could hypothetically persist over charm upgrades and therefore ops library upgrades and downgrades, and the run-time check against supported values is therefore needed should the allow list ever change.

In any case, good call to keep this PR small and leave more extensive changes for later / slower process.

@james-garner-canonical
Copy link
Contributor Author

Interesting observation about data persisting across library versions. I definitely wouldn't want to remove any runtime checks when adding type checks (though here it would just fail with a relatively traceable KeyError on dictionary access).

The data flow you've pointed out in loki could indeed simply be annotated with Literals, which would make it compatible with both the current api and the breaking change (now reverted). Defining the Literal['active', 'blocked', 'maintenance', 'waiting', 'error', 'unknown'] is a bit painful for users, and isn't very 'single source of truth', especially when thinking about changing ops/juju versions, so I wouldn't want to recommend this pattern to library users generally.

I wonder if it would be worth making the StatusName (and SettableStatusName?) -- type aliases for Literals -- part of the public api, if not in this PR, then perhaps in future.

Copy link
Contributor

@tonyandrewmeyer tonyandrewmeyer left a comment

Choose a reason for hiding this comment

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

Generally looks good - a couple of small notes/queries.

One other thing I noticed while reviewing the docs to see if they fit with this: the add_status method accepts both error and unknown, and the doc lists those. It reads very much like you can add those statuses, but especially for error if you include that then it'll definitely fail because it will "win" the evaluation and then can't be set.

Maybe it makes this PR too big to adjust that, but if so we should probably open a ticket about that too.

ops/model.py Outdated Show resolved Hide resolved
ops/model.py Outdated Show resolved Hide resolved
ops/model.py Show resolved Hide resolved
ops/model.py Show resolved Hide resolved
ops/model.py Outdated Show resolved Hide resolved
@dimaqq
Copy link
Contributor

dimaqq commented Sep 4, 2024

115 out of 115 (100%) runs passed. 🚀

I would have imagined at least some charm to explicitly set
"error" for good measure, which is now disallowed, but alas! no such thing 😅

ops/model.py Outdated Show resolved Hide resolved
test/test_model.py Outdated Show resolved Hide resolved
ops/model.py Outdated Show resolved Hide resolved
ops/model.py Outdated Show resolved Hide resolved
Unclear how to narrow type of value given that type checking doesn't
excellently support properties with differing types for getter and
setter, so sticking with a simple `cast` for now.
Either work, as we're just testing that the type not being bool or str
raises an error, but bare object may raise eyebrows for readers.
InvalidStatusError is a subclass of ModelError, so it shouldn't break
any user code, and anyway it doesn't look like people were setting
invalid statuses since Juju would give them an error at runtime
currently.
@james-garner-canonical
Copy link
Contributor Author

Generally looks good - a couple of small notes/queries.

One other thing I noticed while reviewing the docs to see if they fit with this: the add_status method accepts both error and unknown, and the doc lists those. It reads very much like you can add those statuses, but especially for error if you include that then it'll definitely fail because it will "win" the evaluation and then can't be set.

Maybe it makes this PR too big to adjust that, but if so we should probably open a ticket about that too.

I've opened an issue to discuss this (#1356).

@james-garner-canonical
Copy link
Contributor Author

I'm going to defer work on (#1356 -- i.e. making it an error to call add_status with ErrorStatus or UnknownStatus) to a future PR. Pending finalising the name for the _StatusName / _SettableStatusName type aliases (see #1354 (comment)), I don't anticipate doing further work on this PR in the meantime. There isn't really any rush for getting it finalised, but I would welcome any feedback on the names. For ease of reference, I'll post my two ideas below, but I'm open to suggestions.

_ReadOnlyStatusName = Literal['error', 'unknown']
_SettableStatusName = Literal['active', 'blocked', 'maintenance', 'waiting']
_StatusName = Union[_SettableStatusName, _ReadOnlyStatusName]
_StatusDict = TypedDict('_StatusDict', {'status': _StatusName, 'message': str})
_SETTABLE_STATUS_NAMES: Tuple[_SettableStatusName, ...] = get_args(_SettableStatusName)

Prefixes, but slightly more comprehensible in context?

_StatusNameReadOnly = Literal['error', 'unknown']
_StatusNameSettable = Literal['active', 'blocked', 'maintenance', 'waiting']
_StatusName = Union[_StatusNameSettable, _StatusNameReadOnly]
_StatusDict = TypedDict('_StatusDict', {'status': _StatusName, 'message': str})
_SETTABLE_STATUS_NAMES: Tuple[_StatusNameSettable, ...] = get_args(_StatusNameSettable)

Are suffixes better here?

ops/model.py Outdated Show resolved Hide resolved
ops/model.py Outdated Show resolved Hide resolved
Calling add_status with an ErrorStatus *will* eventually result in
an InvalidStatusError. Calling add_status with an UnknownStatus *may*
eventually result in an InvalidStatusError.
@james-garner-canonical
Copy link
Contributor Author

I think this is ready for a final review and merge if anyone wants to take another look and provide the second approval (@tonyandrewmeyer, @benhoyt).

Copy link
Collaborator

@benhoyt benhoyt left a comment

Choose a reason for hiding this comment

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

Looks good, just a couple of minor comments.

ops/charm.py Outdated Show resolved Hide resolved
ops/model.py Show resolved Hide resolved
ops/model.py Show resolved Hide resolved
@james-garner-canonical
Copy link
Contributor Author

Discussed changes have been made, happy to approve and merge @benhoyt ?

Copy link
Collaborator

@benhoyt benhoyt left a comment

Choose a reason for hiding this comment

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

Yep, looks good, thanks!

@james-garner-canonical james-garner-canonical merged commit 483579a into canonical:main Sep 18, 2024
29 checks passed
tonyandrewmeyer pushed a commit to tonyandrewmeyer/operator that referenced this pull request Oct 4, 2024
…set_status (canonical#1354)

Document the status values that are valid arguments for
`_ModelBackend.status_set`, as requested in canonical#1343, via type annotation.
The `status` argument was previously type hinted only as `str`. However,
this argument can really only be one of four valid statuses that Juju's
`status-set` will accept `('active', 'blocked', 'maintenance',
'waiting')`.

Since the arguments to `status_set` are already partially validated,
(`is_app` being a bool in `status_set`, `status` and `message` being
strings in `Application.status` and `Unit.status`), and and the valid
values for `status` are now encoded and associated with this method, we
can check that `status` is valid in `status_set` and raise an
`InvalidStatusError` immediately if not, rather than catching `status-set`'s
exit code and only then raising a `ModelError`.

Tests are updated to account for this (`test_collect_status_priority`
split into `test_collect_status_priority_valid` and
`test_collect_status_priority_invalid` since `status-set` isn't called
in the invalid case, and `test_local_set_invalid_status` is updated to
account for this too).

Since `is_app` and `status` are both validated in `status_set`, it makes
sense to also validate the type of `message` here, removing redundant
checks in `Application.status` and cleaning up a `fixme` in
`Unit.status`. A test is added to cover this
(`test_status_set_message_not_str_raises`).

`StatusBase.name` is also now annotated as only being one of the six valid
Juju statuses, and the type alias for this literal (`StatusName`) is
exposed in the public api.

Finally, a couple of broken unit tests that interact with
`StatusBase` and `status_set` (`test_base_status_instance_raises` and
`test_status_set_is_app_not_bool_raises` respectively) are now fixed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants