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 10 commits into
base: master
Choose a base branch
from
2 changes: 0 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,6 @@ module = [
"sentry.api.endpoints.project_rules_configuration",
"sentry.api.endpoints.project_servicehook_stats",
"sentry.api.endpoints.project_transaction_names",
"sentry.api.endpoints.rule_snooze",
"sentry.api.endpoints.team_details",
"sentry.api.endpoints.team_release_count",
"sentry.api.endpoints.user_subscriptions",
Expand All @@ -182,7 +181,6 @@ module = [
"sentry.api.serializers.models.team",
"sentry.api.serializers.rest_framework.mentions",
"sentry.api.serializers.rest_framework.notification_action",
"sentry.api.serializers.rest_framework.rule",
"sentry.auth.helper",
"sentry.auth.provider",
"sentry.auth.system",
Expand Down
39 changes: 27 additions & 12 deletions src/sentry/api/endpoints/rule_snooze.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from sentry.api.exceptions import BadRequest
from sentry.api.serializers import Serializer, register, serialize
from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer
from sentry.db.models.manager.base_query_set import BaseQuerySet
from sentry.incidents.models.alert_rule import AlertRule
from sentry.models.organization import Organization
from sentry.models.organizationmember import OrganizationMember
Expand Down Expand Up @@ -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!

rule_field = Literal["rule", "alert_rule"]

def convert_args(self, request: Request, rule_id: int, *args, **kwargs):
(args, kwargs) = super().convert_args(request, *args, **kwargs)
project = kwargs["project"]
try:
if self.rule_model is AlertRule:
queryset: BaseQuerySet[AlertRule | Rule]
if issubclass(self.rule_model, AlertRule):
queryset = self.rule_model.objects.fetch_for_project(project)
else:
queryset = self.rule_model.objects.filter(project=project)
Expand All @@ -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

)

kwargs = {self.rule_field: rule}

user_id = request.user.id if data.get("target") == "me" else None

# 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)
rule_parameter = rule if isinstance(rule, Rule) else None
alert_rule_parameter = None if isinstance(rule, Rule) else rule

rule_snooze, created = RuleSnooze.objects.get_or_create(
user_id=user_id,
rule=rule_parameter,
alert_rule=alert_rule_parameter,
defaults={
"owner_id": request.user.id,
"until": data.get("until"),
"date_added": datetime.datetime.now(datetime.UTC),
},
**kwargs,
)
# don't allow editing of a rulesnooze object for a given rule and user (or no user)
if not created:
Expand Down Expand Up @@ -158,9 +165,14 @@ def delete(self, request: Request, project: Project, rule: Rule | AlertRule) ->
# find if there is a mute for all that I can remove
shared_snooze = None
deletion_type = None
kwargs = {self.rule_field: rule, "user_id": None}
try:
shared_snooze = RuleSnooze.objects.get(**kwargs)
# 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.

rule_parameter = rule if isinstance(rule, Rule) else None
alert_rule_parameter = None if isinstance(rule, Rule) else rule
shared_snooze = RuleSnooze.objects.get(
rule=rule_parameter, alert_rule=alert_rule_parameter, user_id=None
)
except RuleSnooze.DoesNotExist:
pass

Expand 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.

kwargs = {self.rule_field: rule, "user_id": request.user.id}
my_snooze = None
try:
my_snooze = RuleSnooze.objects.get(**kwargs)
rule_parameter = rule if isinstance(rule, Rule) else None
alert_rule_parameter = None if isinstance(rule, Rule) else rule
my_snooze = RuleSnooze.objects.get(
rule=rule_parameter, alert_rule=alert_rule_parameter, user_id=request.user.id
)
except RuleSnooze.DoesNotExist:
pass
else:
Expand All @@ -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.

)
# no snooze at all found
return Response(
Expand Down
44 changes: 14 additions & 30 deletions src/sentry/api/serializers/rest_framework/rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from rest_framework.exceptions import ValidationError

from sentry import features
from sentry.api.fields.actor import ActorField
from sentry.constants import MIGRATED_CONDITIONS, SENTRY_APP_ACTIONS, TICKET_ACTIONS
from sentry.models.environment import Environment
from sentry.rules import rules

ValidationError = serializers.ValidationError
from sentry.rules.actions.sentry_apps.notify_event import NotifyEventSentryAppAction


@extend_schema_field(field=OpenApiTypes.OBJECT)
Expand Down Expand Up @@ -46,12 +46,16 @@ def to_internal_value(self, data):
msg = "Invalid node. Could not find '%s'"
raise ValidationError(msg % data["id"])

# instance of a RuleBase class
node = cls(project=self.context["project"], data=data)

# Nodes with user-declared fields will manage their own validation
if node.id in SENTRY_APP_ACTIONS:
if not data.get("hasSchemaFormConfig"):
raise ValidationError("Please configure your integration settings.")
assert isinstance(
node, NotifyEventSentryAppAction
), "node must be an instance of NotifyEventSentryAppAction to use self_validate"
node.self_validate()
return data

Expand All @@ -64,10 +68,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

# Still hacky, but a bit clearer, for each field there can be a list of errors so
# we get the first error's message for each field and then get the first error message from that list
first_error_message: str = [
error_list[0]["message"]
for field, error_list in form.errors.get_json_data().items()
][0]

if first_error != "This field is required.":
raise ValidationError(first_error)
if first_error_message != "This field is required.":
raise ValidationError(first_error_message)

raise ValidationError("Ensure all required fields are filled in.")

Expand Down Expand Up @@ -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

rule.project = self.context["project"]
if "environment" in self.validated_data:
environment = self.validated_data["environment"]
rule.environment_id = int(environment) if environment else environment
if self.validated_data.get("name"):
rule.label = self.validated_data["name"]
if self.validated_data.get("actionMatch"):
rule.data["action_match"] = self.validated_data["actionMatch"]
if self.validated_data.get("filterMatch"):
rule.data["filter_match"] = self.validated_data["filterMatch"]
if self.validated_data.get("actions") is not None:
rule.data["actions"] = self.validated_data["actions"]
if self.validated_data.get("conditions") is not None:
rule.data["conditions"] = self.validated_data["conditions"]
if self.validated_data.get("frequency"):
rule.data["frequency"] = self.validated_data["frequency"]
if self.validated_data.get("owner"):
actor = self.validated_data["owner"].resolve_to_actor()
rule.owner_id = actor.id
rule.owner_user_id = actor.user_id
rule.owner_team_id = actor.team_id
rule.save()
return rule


ACTION_UUID_KEY = "uuid"

Expand Down
Loading