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

feat: remove pebble enum | str unions #1400

Open
wants to merge 13 commits into
base: main
Choose a base branch
from

Conversation

james-garner-canonical
Copy link
Contributor

Resolving #1287, this PR removes the use of str and enum member unions connected to the pebble module.

Generally this just involves typing dicts generated by pebble as containing str values, and using the enum types exclusively in ops. The major change is that unknown values from pebble result in an UNKNOWN member of the enum, rather than being passed around as strs.

Reviewers will notice some block comments calling attention to potential issues with the current code, questions about my design choices, and suggestions for further changes. Feedback on these areas would be especially appreciated.

raise ValueError(
'self.current must be a ServiceStatus member (e.g. ServiceStatus.ACTIVE)'
f', not {self.current}'
)
return self.current == ServiceStatus.ACTIVE
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This comparison would be silently False if self.current was accidentally a str.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Would a better solution be to switch to using enum.StrEnum instead of enum.Enum? This would seem to match the intent of the original code better. With StrEnum, ServiceStatus.ACTIVE == 'active', so passing around strings instead of enum members would silently succeed instead of silently failing in the absence of a type check.

Copy link
Contributor

Choose a reason for hiding this comment

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

StrEnum is a py 3.11 feature, isn't it?

We've got to support py 3.8 because Juju supports specific Ubuntu releases as bases and that's where the Python interpreter comes from.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, agreed. Unfortunately we can't use StrEnum yet.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch, @dimaqq, thanks.

self.level = CheckLevel.UNKNOWN
#
# these are all Optional in CheckDict and here, why '' instead of None?
# note that all falsey values are filtered out in to_dict
self.period: Optional[str] = dct.get('period', '')
self.timeout: Optional[str] = dct.get('timeout', '')
self.threshold: Optional[int] = dct.get('threshold')
Copy link
Contributor Author

Choose a reason for hiding this comment

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

In general a lot of values are marked as Optional when they could perhaps be simply left out of the TypedDict. I haven't fully checked what the values received from Pebble for these dicts can be -- can these items be the go version of None (null? nil?). It seems like if so, we should use None instead of '', and if not we shouldn't use Optional.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm, I'd have to look a bit closer, but I think these will be missing or str in the YAML. So that probably means NotRequired in the TypedDict? In any case, if you want to address this, let's do it in that separate PR.

# so do we want to just use 'unknown' for CheckLevel.UNKNOWN here?
# we still get a warning on making `c1` but not on `c2`, but maybe that's ok
# but is it useful to have 'unknown' in the dict?
# It'll error if that value makes it to pebble, right?
fields = [
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not really sure what behaviour we want here with CheckLevel.UNKNOWN.

# while we might want <some-unknown-value-string> to overwrite,
# would we ever want Checklevel.UNKNOWN to overwrite?
if value is CheckLevel.UNKNOWN:
continue
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think we should just use other.to_dict() here, is there a reason we weren't already?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I see why you want to use other.to_dict() here, but it doesn't seem the right thing here: we're merging two instances of the class, so it seems weird to convert one to a dict first.

Let's just avoid the special case here and the unknown will be merged (and probably eventually fail at the Pebble level). That seems okay here.

@@ -3046,6 +3105,9 @@ def get_checks(
"""
query: Dict[str, Any] = {}
if level is not None:
# is this bad now that value could be 'unknown'?
# should we explicitly avoid it here?
# if level is not None and level.value is not CheckLevel.UNKNOWN:
query['level'] = level.value
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Another case where it's not clear how to handle CheckLevel.UNKNOWN on the outgoing side.

@@ -3098,7 +3160,7 @@ def get_notices(
*,
users: Optional[NoticesUsers] = None,
user_id: Optional[int] = None,
types: Optional[Iterable[Union[NoticeType, str]]] = None,
types: Optional[Iterable[NoticeType]] = None,
keys: Optional[Iterable[str]] = None,
) -> List[Notice]:
"""Query for notices that match all of the provided filters.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wonder if this will break any users.

Copy link
Contributor

Choose a reason for hiding this comment

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

There's not a single user of .get_notices() yet.
Source: charm-analysis and github search.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yep, I'm comfortable with this. Use of notice events themselves are rare; I'd be surprised if anyone actually needed get_notices even if they were using notices. The charm normally wants the event, not to query for them.

assert info.type == 'foobar'
with pytest.warns(UserWarning):
info = pebble.FileInfo.from_dict(d)
assert info.type == pebble.FileType.UNKNOWN
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should we be using a different kind of warning here?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Nah, the default of UserWarning seems fine.

@@ -3863,7 +3863,7 @@ def _notice_matches(
# For example: if user_id filter is set and it doesn't match, return False.
if user_id is not None and not (notice.user_id is None or user_id == notice.user_id):
return False
if types is not None and notice.type not in types:
if types is not None and notice.type.value not in types:
return False
Copy link
Contributor Author

Choose a reason for hiding this comment

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

types is a collection of strs. I think this is mimicking the logic of pebble, so I think it makes sense for us to use notice.type.value here. Was this always silently broken? Or silently worked because notice.type was often a str -- while now it's supposed to always be an enum member?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think you can answer that question, as notices are not really used by charmers yet.
Which means that the question can be rephrased as "do we have a test for this harness feature?" and "does that test actually work?".

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, this was almost certainly silently broken. I should have written a test for this. Can you please add a regression test in test_testing.py?

@dimaqq
Copy link
Contributor

dimaqq commented Oct 1, 2024

Only superficial comments from me, I didn't read the change properly yet.

@@ -3863,7 +3863,7 @@ def _notice_matches(
# For example: if user_id filter is set and it doesn't match, return False.
if user_id is not None and not (notice.user_id is None or user_id == notice.user_id):
return False
if types is not None and notice.type not in types:
if types is not None and notice.type.value not in types:
return False
Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, this was almost certainly silently broken. I should have written a test for this. Can you please add a regression test in test_testing.py?

raise ValueError(
'self.current must be a ServiceStatus member (e.g. ServiceStatus.ACTIVE)'
f', not {self.current}'
)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should drop this runtime check, as 1) recently we've been leaning more heavily on static typing rather than runtime type checks, and 2) it can basically never happen, especially now, given that these objects are parsed via from_dict and not created by the user (though I realise we don't prevent that).

raise ValueError(
'self.current must be a ServiceStatus member (e.g. ServiceStatus.ACTIVE)'
f', not {self.current}'
)
return self.current == ServiceStatus.ACTIVE
Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, agreed. Unfortunately we can't use StrEnum yet.

@@ -1098,11 +1107,16 @@ def __init__(self, name: str, raw: Optional[CheckDict] = None):
self.name = name
dct: CheckDict = raw or {}
self.override: str = dct.get('override', '')
self.level: CheckLevel
dct_level = dct.get('level', '')
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: dct is confusing here. How about level_str or raw_level?

@@ -1098,11 +1107,16 @@ def __init__(self, name: str, raw: Optional[CheckDict] = None):
self.name = name
dct: CheckDict = raw or {}
self.override: str = dct.get('override', '')
self.level: CheckLevel
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this line needed?

@@ -3046,6 +3105,9 @@ def get_checks(
"""
query: Dict[str, Any] = {}
if level is not None:
# is this bad now that value could be 'unknown'?
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think do the same as earlier: put it through as the string "unknown" and let Pebble fail it (in practice it's extremely unlikely to happen, as we haven't added any new levels since they first came out).

@@ -3098,7 +3160,7 @@ def get_notices(
*,
users: Optional[NoticesUsers] = None,
user_id: Optional[int] = None,
types: Optional[Iterable[Union[NoticeType, str]]] = None,
types: Optional[Iterable[NoticeType]] = None,
keys: Optional[Iterable[str]] = None,
) -> List[Notice]:
"""Query for notices that match all of the provided filters.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Yep, I'm comfortable with this. Use of notice events themselves are rare; I'd be surprised if anyone actually needed get_notices even if they were using notices. The charm normally wants the event, not to query for them.

assert info.type == 'foobar'
with pytest.warns(UserWarning):
info = pebble.FileInfo.from_dict(d)
assert info.type == pebble.FileType.UNKNOWN
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nah, the default of UserWarning seems fine.

for current in [pebble.ServiceStatus.INACTIVE, pebble.ServiceStatus.ERROR, 'other']:
for current in (
status for status in pebble.ServiceStatus if status is not pebble.ServiceStatus.ACTIVE
):
Copy link
Collaborator

Choose a reason for hiding this comment

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

The nested looping here is hard to read, especially split on multiple lines. I think let's either keep the explicit list, or do it on two lines, something like:

inactive_statuses = [status for status in ServiceStatus if status != ACTIVE]
for current in inactive_statuses: ...

James and I discussed whether we should use "is" or "==" to compare enums, and mildly settled on "let's use ==" as it's what you'd do with regular values (and StrEnum or whatever).

s = pebble.ServiceInfo(
name='s',
startup=pebble.ServiceStartup.ENABLED,
current='active', # pyright: ignore[reportArgumentType]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Per earlier comment, I think we should drop this runtime check and just let static type checking handle this case.

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.

3 participants