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

ref(rules): Fix typing for rule_snooze & rule #80306

Open
wants to merge 7 commits into
base: master
Choose a base branch
from

Conversation

Christinarlong
Copy link
Contributor

Trying a new thing where we try and do all (read: most) of the typing upfront and then move the modules to their new home.

issue ref #73858

@github-actions github-actions bot added the Scope: Backend Automatically applied to PRs that change backend components label Nov 6, 2024
Copy link

codecov bot commented Nov 6, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Additional details and impacted files
@@             Coverage Diff             @@
##           master   #80306       +/-   ##
===========================================
+ Coverage   51.11%   78.10%   +26.99%     
===========================================
  Files        7154     7185       +31     
  Lines      315858   317409     +1551     
  Branches    43555    43735      +180     
===========================================
+ Hits       161444   247915    +86471     
+ Misses     153294    63154    -90140     
- Partials     1120     6340     +5220     

@@ -76,14 +77,15 @@ def serialize(self, obj, attrs, user, **kwargs):
@region_silo_endpoint
class BaseRuleSnoozeEndpoint(ProjectEndpoint):
permission_classes = (ProjectAlertRulePermission,)
rule_model = type[Rule] | type[AlertRule]
rule_model: type[AlertRule | Rule]
Copy link
Contributor Author

@Christinarlong Christinarlong Nov 6, 2024

Choose a reason for hiding this comment

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

Moved back from Generic to union typehint, since the Model bound was giving causing mypy to freak out over what T could be. E.g in lines 87-89/89-91 where we access BaseManager[AlertRule] to get fetch_for_project but mypy says BaseManager[T] doesn't have access to that func.....

Copy link
Member

Choose a reason for hiding this comment

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

might be useful to have an # abstract comment on this just so it's clear

btw the original code was a type alias so I'm surprised this worked at all!

@@ -64,10 +69,15 @@ def to_internal_value(self, data):
# XXX(epurkhiser): Very hacky, but we really just want validation
# errors that are more specific, not just 'this wasn't filled out',
# give a more generic error for those.
first_error = next(iter(form.errors.values()))[0]
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Interestingly, by using .values() on ErrorDict the list of ValidationError(<message>) turns into a list of strings. mypy doesn't pick up on that (django-stubs doesn't say so either) and assumes first_error is a str | ValidationError. The below way feels a bit more "stable", since I'm not sure if that transformation of ValidationError -> str via .values() is totally intentional.

Copy link
Member

Choose a reason for hiding this comment

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

so the : str here shouldn't do anything (and shouldn't be there!) -- and actually it looks like the typechecker is correct

form.errors is a ErrorsDict which is a dict[str, ErrorsList] and ErrorsList is a UserList[ValidationError | _StrOrPromise]

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yesssss, though the .values() transformation makes the result a UserList[_StrOrPromise] in reality, so I guess we could just type hint the variable to that.

Copy link
Member

Choose a reason for hiding this comment

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

I would just remove the annotation personally

@Christinarlong Christinarlong marked this pull request as ready for review November 6, 2024 18:49
@Christinarlong Christinarlong requested a review from a team as a code owner November 6, 2024 18:49
@@ -168,31 +177,6 @@ def validate_conditions(self, conditions):
def validate(self, attrs):
return super().validate(validate_actions(attrs))

def save(self, rule):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

No code ran/referenced this ._.

Copy link
Member

Choose a reason for hiding this comment

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

I think this gets called from rest_framework maybe?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

'o' is .save() run implicitly when people instantiate the serializer ? I couldn't find any explicit calls for .save(), and this method doesn't implement any of the create() or update(). So, we should be safe ?

Copy link
Member

Choose a reason for hiding this comment

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

DRF won't call save() automatically, it would be a method call we're making. However, I wasn't able to find any callsites for this method either.

Copy link
Member

Choose a reason for hiding this comment

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

I am not sure -- I thought it was magically invoked by forms somehow -- could probably trace when this function was introduced and see how it was used in that PR and then from there figure out if that code path is still used

Copy link
Contributor Author

Choose a reason for hiding this comment

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

:0) good thinking, looks like this (vintage) function used to be used in the rules endpoints, but now we use dataclasses (see: ProjectRuleCreator & ProjectRuleUpdater) that do the same actions instead

@@ -168,31 +177,6 @@ def validate_conditions(self, conditions):
def validate(self, attrs):
return super().validate(validate_actions(attrs))

def save(self, rule):
Copy link
Member

Choose a reason for hiding this comment

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

I think this gets called from rest_framework maybe?

@@ -64,10 +69,15 @@ def to_internal_value(self, data):
# XXX(epurkhiser): Very hacky, but we really just want validation
# errors that are more specific, not just 'this wasn't filled out',
# give a more generic error for those.
first_error = next(iter(form.errors.values()))[0]
Copy link
Member

Choose a reason for hiding this comment

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

so the : str here shouldn't do anything (and shouldn't be there!) -- and actually it looks like the typechecker is correct

form.errors is a ErrorsDict which is a dict[str, ErrorsList] and ErrorsList is a UserList[ValidationError | _StrOrPromise]

Comment on lines 169 to 170
# Due to mypy requiring keyword arguments to not be kwargs, we need to check the type of rule and query accordingly
# In RuleSnooze, rule and alert_rule are mutually exclusive (cannot both be not null)
Copy link
Member

Choose a reason for hiding this comment

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

the better fix is to give kwargs a TypedDict and leave the code as is

Copy link
Member

Choose a reason for hiding this comment

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

same above too!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

nvm 🥲 it turns out a union of Literals in a variable key for a typed dict isn't supported by mypy currently (python/mypy#7752)

class RuleField(TypedDict, total=False):
     rule: Rule
     alert_rule: AlertRule

kwargs: RuleField = {self.rule_field: rule}

Copy link
Member

Choose a reason for hiding this comment

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

really starting to think this base class is a maintenance problem and trying to be too cute -- and that these are actually two disparate things trying to be shoved into a BaseRuleSnoozeEndpoint shaped box

Copy link
Contributor Author

@Christinarlong Christinarlong Nov 7, 2024

Choose a reason for hiding this comment

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

yeahhhhh like we could delegate the RuleSnooze call to the child classes, and keep the rest of the logic in a helper in the base case, since the main conflict is that the base class doesn't know if it wants rule or alert_rule in the db call.

Copy link
Member

Choose a reason for hiding this comment

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

yeah or a few composable functions would probably work too!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wait, can you elaborate more on what composable functions means in this context ? The google tells me composable functions when we chain functions together/when functions take in other functions but I'm not sure how that would help in this case :thonk

Copy link
Member

Choose a reason for hiding this comment

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

maybe won't need to compose -- but can probably make two separate functions for create_rule_snooze and delete_rule_snooze and then call them with the appropriate arguments in the two "subclasses" here -- basically undo the inheritance and instead have free functions which accomplish the same thing (but are less reliant on weird conventions of self to shoehorn in the right arguments).

Copy link
Member

Choose a reason for hiding this comment

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

Pushing the differences in the subclasses into methods/functions could work. We have similar patterns for the OrganizationUnsubscribe endpoints and AvatarMixin.

@@ -104,20 +106,25 @@ def post(self, request: Request, project: Project, rule: Rule | AlertRule) -> Re

if not can_edit_alert_rule(project.organization, request):
raise PermissionDenied(
detail="Requesting user cannot mute this rule.", code=status.HTTP_403_FORBIDDEN
detail="Requesting user cannot mute this rule.", code=str(status.HTTP_403_FORBIDDEN)
Copy link
Member

Choose a reason for hiding this comment

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

the str(403) seems suspicious -- what was this fixing? do we even need code= (surely a PermissionDenied is always going to be a 403 right?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yeah, good pt. the 403 comes included in the PermissionDenied exception class already

@@ -76,14 +77,15 @@ def serialize(self, obj, attrs, user, **kwargs):
@region_silo_endpoint
class BaseRuleSnoozeEndpoint(ProjectEndpoint):
permission_classes = (ProjectAlertRulePermission,)
rule_model = type[Rule] | type[AlertRule]
rule_model: type[AlertRule | Rule]
Copy link
Member

Choose a reason for hiding this comment

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

might be useful to have an # abstract comment on this just so it's clear

btw the original code was a type alias so I'm surprised this worked at all!

@@ -169,11 +181,13 @@ def delete(self, request: Request, project: Project, rule: Rule | AlertRule) ->
shared_snooze.delete()
deletion_type = "everyone"

# next check if there is a mute for me that I can remove
Copy link
Member

Choose a reason for hiding this comment

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

It would be good to retain this comment.

@@ -197,7 +211,8 @@ def delete(self, request: Request, project: Project, rule: Rule | AlertRule) ->
# didn't find a match but there is a shared snooze
if shared_snooze:
raise PermissionDenied(
detail="Requesting user cannot unmute this rule.", code=status.HTTP_403_FORBIDDEN
detail="Requesting user cannot unmute this rule.",
code=str(status.HTTP_403_FORBIDDEN),
Copy link
Member

Choose a reason for hiding this comment

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

This code parameter can likely be removed.

@@ -168,31 +177,6 @@ def validate_conditions(self, conditions):
def validate(self, attrs):
return super().validate(validate_actions(attrs))

def save(self, rule):
Copy link
Member

Choose a reason for hiding this comment

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

DRF won't call save() automatically, it would be a method call we're making. However, I wasn't able to find any callsites for this method either.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Scope: Backend Automatically applied to PRs that change backend components
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants