From 0cafdc5a6f27c95ed5dc126e14640b27f7dad809 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 09:49:09 -0700 Subject: [PATCH 001/103] ref(typing): promote campaign modules to precise typing contracts Rebuild the accepted typing campaign on current master as an annotation-only change. Excludes the campaign evaluator, its policy modules, the campaign log, and owner-owned baseline edits. Behavior changes are carved into separate PRs; this commit preserves runtime behavior, including ReleaseFile.save positional compatibility and the release serializer's existing stats and newGroups output. Fix two refactor regressions: starred group search views keep their createdBy/starred/stars fields, and Seer commit lookups stay keyed by primary key instead of sha. Allow the weaklist guard to accept narrowing a wildcard into named modules, which is how the flags endpoints wildcard graduates. --- pyproject.toml | 105 +---- src/bitfield/models.py | 41 +- src/bitfield/query.py | 2 +- src/bitfield/types.py | 105 +++-- src/sentry/api/bases/avatar.py | 26 +- .../api/endpoints/api_application_details.py | 6 +- src/sentry/api/endpoints/api_token_details.py | 8 +- src/sentry/api/endpoints/api_tokens.py | 2 +- src/sentry/api/endpoints/broadcast_details.py | 6 +- src/sentry/api/endpoints/catchall.py | 2 +- src/sentry/api/endpoints/email_capture.py | 2 +- .../api/endpoints/event_apple_crash_report.py | 3 +- src/sentry/api/endpoints/event_attachments.py | 3 +- .../api/endpoints/event_file_committers.py | 3 +- src/sentry/api/endpoints/filechange.py | 3 +- src/sentry/api/endpoints/internal/beacon.py | 24 +- .../organization_access_request_details.py | 2 +- .../endpoints/organization_api_key_details.py | 2 +- .../endpoints/organization_api_key_index.py | 18 +- .../organization_artifactbundle_assemble.py | 3 +- .../organization_auth_token_details.py | 12 +- src/sentry/api/endpoints/organization_fork.py | 2 +- .../organization_onboarding_tasks.py | 2 +- .../api/endpoints/organization_pipeline.py | 3 +- .../endpoints/organization_project_keys.py | 5 +- .../endpoints/organization_recent_searches.py | 5 +- .../api/endpoints/organization_releases.py | 5 +- .../api/endpoints/organization_stats.py | 3 +- .../endpoints/organization_tagkey_values.py | 3 +- .../project_artifact_bundle_file_details.py | 5 +- src/sentry/api/endpoints/project_commits.py | 3 +- .../api/endpoints/project_filter_details.py | 3 +- src/sentry/api/endpoints/project_filters.py | 3 +- .../api/endpoints/project_member_index.py | 3 +- .../api/endpoints/project_reprocessing.py | 3 +- .../api/endpoints/project_tagkey_values.py | 5 +- src/sentry/api/endpoints/project_tags.py | 5 +- .../endpoints/project_transaction_names.py | 3 +- src/sentry/api/endpoints/relay/details.py | 2 +- src/sentry/api/endpoints/setup_wizard.py | 4 +- src/sentry/api/endpoints/source_map_debug.py | 28 +- .../user_organizationintegrations.py | 3 +- .../api/endpoints/user_subscriptions.py | 6 +- src/sentry/api/handlers.py | 3 +- src/sentry/api/paginator.py | 98 +++-- src/sentry/api/serializers/models/activity.py | 32 +- src/sentry/api/serializers/models/actor.py | 47 ++- .../serializers/models/agentic_onboarding.py | 6 +- .../api/serializers/models/apiapplication.py | 33 +- .../serializers/models/apiauthorization.py | 41 +- src/sentry/api/serializers/models/apikey.py | 26 +- src/sentry/api/serializers/models/apitoken.py | 49 ++- .../api/serializers/models/artifactbundle.py | 65 +++- .../api/serializers/models/auditlogentry.py | 40 +- .../api/serializers/models/broadcast.py | 73 +++- src/sentry/api/serializers/models/commit.py | 60 ++- .../api/serializers/models/dashboard.py | 155 +++++--- .../api/serializers/models/debug_file.py | 9 +- .../serializers/models/discoversavedquery.py | 32 +- .../api/serializers/models/environment.py | 19 +- src/sentry/api/serializers/models/event.py | 149 +++++-- .../api/serializers/models/eventattachment.py | 13 +- .../api/serializers/models/eventuser.py | 26 +- .../serializers/models/exploresavedquery.py | 34 +- .../api/serializers/models/exporteddata.py | 26 +- .../api/serializers/models/filechange.py | 48 ++- src/sentry/api/serializers/models/group.py | 120 +++--- .../api/serializers/models/group_stream.py | 61 +-- .../api/serializers/models/grouprelease.py | 52 ++- .../api/serializers/models/groupsearchview.py | 76 ++-- .../models/groupsearchviewstarred.py | 44 ++- .../api/serializers/models/grouptombstone.py | 40 +- .../api/serializers/models/organization.py | 4 +- .../models/organization_access_request.py | 46 ++- .../models/organization_member/response.py | 2 +- .../api/serializers/models/orgauthtoken.py | 33 +- .../models/profilechunkattachment.py | 6 +- src/sentry/api/serializers/models/project.py | 78 ++-- .../api/serializers/models/project_key.py | 15 +- .../serializers/models/project_platform.py | 23 +- .../serializers/models/project_repository.py | 18 +- .../models/project_transaction_threshold.py | 44 ++- .../serializers/models/projectcodeowners.py | 26 +- .../serializers/models/projectownership.py | 21 +- .../api/serializers/models/pullrequest.py | 19 +- .../api/serializers/models/recentsearch.py | 27 +- .../api/serializers/models/relayusage.py | 15 +- src/sentry/api/serializers/models/release.py | 107 ++++-- .../api/serializers/models/release_file.py | 18 +- .../serializers/models/release_threshold.py | 31 +- .../api/serializers/models/repository.py | 25 +- src/sentry/api/serializers/models/role.py | 24 +- .../models/seer_agentic_triage_run.py | 13 +- src/sentry/api/serializers/models/seer_run.py | 18 +- src/sentry/api/serializers/models/tagvalue.py | 35 +- src/sentry/api/serializers/models/team.py | 4 +- .../models/trace_item_attribute_context.py | 13 +- .../trace_item_attribute_value_context.py | 13 +- .../serializers/models/user_social_auth.py | 22 +- .../api/serializers/models/userreport.py | 60 ++- .../api/serializers/release_details_types.py | 10 +- .../api/serializers/rest_framework/commit.py | 2 +- .../serializers/rest_framework/dashboard.py | 16 +- .../api/serializers/rest_framework/origin.py | 9 +- .../serializers/rest_framework/project_key.py | 9 +- src/sentry/api/validators/auth.py | 10 +- src/sentry/api/validators/project.py | 6 +- src/sentry/apidocs/extensions.py | 6 +- src/sentry/attachments/default.py | 2 +- src/sentry/audit_log/events.py | 2 +- src/sentry/backup/dependencies.py | 36 +- .../core/endpoints/organization_avatar.py | 7 +- .../core/endpoints/organization_details.py | 64 +-- ...nization_member_requests_invite_details.py | 8 +- src/sentry/core/endpoints/project_transfer.py | 10 +- src/sentry/core/endpoints/team_avatar.py | 5 +- src/sentry/db/models/fields/bounded.py | 4 + src/sentry/db/models/fields/onetoone.py | 6 +- src/sentry/debug_files/artifact_bundles.py | 4 +- src/sentry/discover/arithmetic.py | 6 +- src/sentry/exceptions.py | 4 +- .../endpoints/explore_saved_queries.py | 9 +- .../endpoints/explore_saved_query_detail.py | 8 +- .../endpoints/explore_saved_query_starred.py | 13 +- src/sentry/explore/endpoints/serializers.py | 101 ++++- src/sentry/identity/datadog/provider.py | 4 +- src/sentry/identity/gcp/provider.py | 2 +- src/sentry/identity/oauth2.py | 16 +- .../insights/endpoints/starred_segments.py | 9 +- .../api/endpoints/doc_integration_avatar.py | 7 +- .../rest_framework/data_forwarder.py | 47 +-- src/sentry/integrations/aws_lambda/utils.py | 36 +- src/sentry/integrations/bitbucket/client.py | 75 ++-- .../integrations/bitbucket/integration.py | 17 +- src/sentry/integrations/bitbucket/issues.py | 15 +- .../integrations/bitbucket/repository.py | 22 +- src/sentry/integrations/bitbucket/search.py | 4 +- .../integrations/bitbucket_server/client.py | 53 ++- .../bitbucket_server/integration.py | 21 +- src/sentry/integrations/github/issues.py | 7 +- src/sentry/integrations/github/types.py | 2 +- src/sentry/integrations/gitlab/client.py | 363 ++++++++++++------ src/sentry/integrations/gitlab/integration.py | 2 +- src/sentry/integrations/gitlab/issues.py | 17 +- src/sentry/integrations/gitlab/repository.py | 112 ++++-- src/sentry/integrations/gitlab/utils.py | 2 +- src/sentry/integrations/gitlab/webhooks.py | 16 +- src/sentry/integrations/jira/client.py | 17 +- src/sentry/integrations/jira/integration.py | 52 ++- src/sentry/integrations/jira_server/client.py | 74 ++-- .../integrations/jira_server/integration.py | 66 +++- src/sentry/integrations/jira_server/search.py | 2 +- src/sentry/integrations/mixins/issues.py | 2 +- src/sentry/integrations/msteams/client.py | 49 ++- src/sentry/integrations/opsgenie/client.py | 6 +- src/sentry/integrations/types.py | 8 +- src/sentry/integrations/utils/metrics.py | 2 +- src/sentry/integrations/vsts/issues.py | 2 +- src/sentry/interfaces/geo.py | 5 +- .../endpoints/organization_group_index.py | 12 +- src/sentry/lang/javascript/processing.py | 6 +- src/sentry/lang/native/error.py | 31 +- src/sentry/lang/native/processing.py | 2 +- src/sentry/lang/native/sources.py | 19 +- src/sentry/lang/native/utils.py | 26 +- src/sentry/middleware/health.py | 4 +- src/sentry/models/artifactbundle.py | 8 +- src/sentry/models/dashboard_widget.py | 2 +- src/sentry/models/files/abstractfile.py | 27 +- src/sentry/models/organization.py | 5 +- src/sentry/models/organizationmember.py | 133 ++++--- src/sentry/models/orgauthtoken.py | 4 +- src/sentry/models/projectkey.py | 43 ++- src/sentry/models/projectredirect.py | 9 +- src/sentry/models/release.py | 32 +- src/sentry/models/releasefile.py | 132 ++++--- src/sentry/net/http.py | 26 +- .../endpoints/notification_actions_details.py | 4 +- .../endpoints/user_notification_details.py | 18 +- .../api/endpoints/user_notification_email.py | 5 +- ...er_notification_settings_options_detail.py | 2 +- .../notification_action_request.py | 8 +- .../notification_action_response.py | 22 +- .../models/notificationaction.py | 6 +- .../notifications/notificationcontroller.py | 10 +- .../abstract_invite_request.py | 2 +- .../role_based_recipient_strategy.py | 2 +- src/sentry/notifications/platform/service.py | 2 +- src/sentry/notifications/utils/__init__.py | 9 +- src/sentry/notifications/validators.py | 22 +- src/sentry/options/manager.py | 16 +- src/sentry/options/store.py | 37 +- .../providers/integration_repository.py | 2 +- src/sentry/quotas/base.py | 84 ++-- src/sentry/quotas/redis.py | 14 +- src/sentry/receivers/features.py | 26 +- src/sentry/relay/config/__init__.py | 4 +- src/sentry/relay/datascrubbing.py | 7 +- .../endpoints/organization_release_details.py | 92 +++-- .../commands/presenters/consolepresenter.py | 6 +- .../commands/presenters/optionspresenter.py | 6 +- .../commands/presenters/webhookpresenter.py | 6 +- src/sentry/sdk_updates.py | 88 +++-- src/sentry/search/events/fields.py | 30 +- src/sentry/search/exceptions.py | 2 +- src/sentry/search/snuba/executors.py | 42 +- src/sentry/seer/agent/tools.py | 36 +- .../sentry_apps/api/bases/sentryapps.py | 31 +- .../api/endpoints/sentry_app_avatar.py | 3 +- .../sentry_apps/api/parsers/sentry_app.py | 39 +- src/sentry/services/filestore/gcs.py | 86 +++-- src/sentry/services/filestore/s3.py | 34 +- src/sentry/snuba/discover.py | 1 + src/sentry/snuba/metrics/query_builder.py | 23 +- src/sentry/tagstore/snuba/backend.py | 6 +- src/sentry/templatetags/sentry_api.py | 16 +- src/sentry/templatetags/sentry_assets.py | 43 ++- src/sentry/templatetags/sentry_features.py | 10 +- src/sentry/testutils/helpers/auth_header.py | 7 +- src/sentry/uptime/endpoints/validators.py | 50 ++- src/sentry/users/api/endpoints/user_avatar.py | 3 +- src/sentry/users/api/serializers/user.py | 12 +- src/sentry/utils/committers.py | 8 +- src/sentry/utils/concurrent.py | 44 ++- src/sentry/utils/javascript.py | 4 +- src/sentry/utils/mockdata/core.py | 33 +- src/sentry/utils/samples.py | 21 +- src/sentry/utils/snuba.py | 35 +- src/sentry/web/frontend/error_404.py | 2 +- src/sentry/web/frontend/project_event.py | 14 +- src/social_auth/backends/__init__.py | 11 +- static/app/types/organization.tsx | 2 +- .../api/endpoints/test_broadcast_index.py | 11 + .../serializers/rest_framework/test_origin.py | 8 +- .../rest_framework/test_project.py | 33 ++ tests/sentry/api/serializers/test_apitoken.py | 13 +- tests/sentry/api/serializers/test_base.py | 20 + .../serializers/test_organization_member.py | 2 + tests/sentry/api/test_paginator.py | 2 +- .../test_insights_starred_segment.py | 11 + ...zation_integration_serverless_functions.py | 4 +- .../aws_lambda/test_integration.py | 4 +- tests/sentry/lang/native/test_processing.py | 14 + tests/sentry/middleware/test_health.py | 3 + .../sentry/models/test_organizationmember.py | 6 +- tests/sentry/models/test_release.py | 4 +- tests/sentry/models/test_releasefile.py | 9 +- tests/sentry/runner/test_initializer.py | 8 +- tests/sentry/scm/unit/private/test_ipc.py | 122 +++--- tests/sentry/stacktraces/test_functions.py | 4 +- tests/sentry/utils/test_cursored_scheduler.py | 100 ++--- .../test_prevent_weaklist_additions.py | 42 ++ .../prevent_weaklist_additions.py | 9 + 253 files changed, 4281 insertions(+), 2027 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 48545dd82b28..5cfdb1c94179 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -451,12 +451,8 @@ module = [ "fixtures.sudo_testutils", "fixtures.vsts", "sentry", - "sentry.__main__", "sentry.adoption.*", "sentry.api", - "sentry.api.analytics", - "sentry.api.api_owners", - "sentry.api.api_publish_status", "sentry.api.authentication", "sentry.api.base", "sentry.api.client", @@ -464,54 +460,37 @@ module = [ "sentry.api.endpoints.accept_organization_invite", "sentry.api.endpoints.accept_project_transfer", "sentry.api.endpoints.admin_project_configs", - "sentry.api.endpoints.api_application_details", - "sentry.api.endpoints.api_application_rotate_secret", "sentry.api.endpoints.api_applications", "sentry.api.endpoints.api_authorizations", - "sentry.api.endpoints.api_token_details", - "sentry.api.endpoints.api_tokens", "sentry.api.endpoints.artifact_bundles", "sentry.api.endpoints.artifact_lookup", "sentry.api.endpoints.assistant", "sentry.api.endpoints.auth_config", "sentry.api.endpoints.auth_index", "sentry.api.endpoints.auth_login", - "sentry.api.endpoints.auth_validate", "sentry.api.endpoints.broadcast_details", "sentry.api.endpoints.broadcast_index", "sentry.api.endpoints.builtin_symbol_sources", - "sentry.api.endpoints.catchall", "sentry.api.endpoints.chunk", "sentry.api.endpoints.data_scrubbing_selector_suggestions", "sentry.api.endpoints.debug_files", - "sentry.api.endpoints.dsn_lookup", - "sentry.api.endpoints.email_capture", - "sentry.api.endpoints.event_apple_crash_report", "sentry.api.endpoints.event_attachment_details", "sentry.api.endpoints.event_attachments", "sentry.api.endpoints.event_file_committers", - "sentry.api.endpoints.filechange", - "sentry.api.endpoints.frontend_version", "sentry.api.endpoints.index", "sentry.api.endpoints.internal.*", "sentry.api.endpoints.oauth_userinfo", - "sentry.api.endpoints.organization_access_request_details", "sentry.api.endpoints.organization_api_key_details", - "sentry.api.endpoints.organization_api_key_index", "sentry.api.endpoints.organization_artifactbundle_assemble", "sentry.api.endpoints.organization_attribute_mappings", - "sentry.api.endpoints.organization_auditlogs", "sentry.api.endpoints.organization_auth_provider_details", - "sentry.api.endpoints.organization_auth_providers", "sentry.api.endpoints.organization_auth_token_details", "sentry.api.endpoints.organization_auth_tokens", - "sentry.api.endpoints.organization_details", "sentry.api.endpoints.organization_events", "sentry.api.endpoints.organization_events_facets", "sentry.api.endpoints.organization_events_meta", "sentry.api.endpoints.organization_events_stats", "sentry.api.endpoints.organization_events_timeseries", - "sentry.api.endpoints.organization_fork", "sentry.api.endpoints.organization_insights_tree", "sentry.api.endpoints.organization_intercom_jwt", "sentry.api.endpoints.organization_measurements_meta", @@ -520,25 +499,18 @@ module = [ "sentry.api.endpoints.organization_on_demand_metrics_estimation_stats", "sentry.api.endpoints.organization_onboarding_continuation_email", "sentry.api.endpoints.organization_onboarding_tasks", - "sentry.api.endpoints.organization_pipeline", "sentry.api.endpoints.organization_profiling_functions", "sentry.api.endpoints.organization_profiling_profiles", - "sentry.api.endpoints.organization_project_keys", - "sentry.api.endpoints.organization_projects_sent_first_event", - "sentry.api.endpoints.organization_recent_searches", - "sentry.api.endpoints.organization_relay_usage", "sentry.api.endpoints.organization_releases", "sentry.api.endpoints.organization_sampling_effective_sample_rate", "sentry.api.endpoints.organization_sampling_project_rates", "sentry.api.endpoints.organization_sampling_project_span_counts", - "sentry.api.endpoints.organization_sdk_deprecations", "sentry.api.endpoints.organization_sdk_updates", "sentry.api.endpoints.organization_sessions", "sentry.api.endpoints.organization_stats", "sentry.api.endpoints.organization_stats_summary", "sentry.api.endpoints.organization_stats_v2", "sentry.api.endpoints.organization_tagkey_values", - "sentry.api.endpoints.organization_tags", "sentry.api.endpoints.organization_trace", "sentry.api.endpoints.organization_trace_item_attributes", "sentry.api.endpoints.organization_trace_item_attributes_ranked", @@ -547,17 +519,11 @@ module = [ "sentry.api.endpoints.organization_trace_meta", "sentry.api.endpoints.organization_traces", "sentry.api.endpoints.organization_unsubscribe", - "sentry.api.endpoints.project_artifact_bundle_file_details", "sentry.api.endpoints.project_artifact_bundle_files", - "sentry.api.endpoints.project_commits", "sentry.api.endpoints.project_create_sample", - "sentry.api.endpoints.project_filter_details", - "sentry.api.endpoints.project_filters", - "sentry.api.endpoints.project_member_index", "sentry.api.endpoints.project_overview", "sentry.api.endpoints.project_performance_general_settings", "sentry.api.endpoints.project_profiling_profile", - "sentry.api.endpoints.project_reprocessing", "sentry.api.endpoints.project_servicehook_details", "sentry.api.endpoints.project_servicehooks", "sentry.api.endpoints.project_statistical_detectors", @@ -573,27 +539,20 @@ module = [ "sentry.api.endpoints.relay.*", "sentry.api.endpoints.seer_models", "sentry.api.endpoints.setup_wizard", - "sentry.api.endpoints.system_health", - "sentry.api.endpoints.system_options", "sentry.api.endpoints.timeseries", "sentry.api.endpoints.user_organizationintegrations", - "sentry.api.endpoints.user_organizations", "sentry.api.endpoints.user_subscriptions", - "sentry.api.endpoints.warmup", "sentry.api.exceptions", "sentry.api.fields.*", "sentry.api.handlers", "sentry.api.invite_helper", "sentry.api.paginator", - "sentry.api.release_search", "sentry.api.serializers", "sentry.api.serializers.base", "sentry.api.serializers.models", "sentry.api.serializers.models.activity", - "sentry.api.serializers.models.actor", "sentry.api.serializers.models.apiapplication", "sentry.api.serializers.models.apiauthorization", - "sentry.api.serializers.models.apikey", "sentry.api.serializers.models.apitoken", "sentry.api.serializers.models.artifactbundle", "sentry.api.serializers.models.auditlogentry", @@ -620,29 +579,19 @@ module = [ "sentry.api.serializers.models.grouptombstone", "sentry.api.serializers.models.organization", "sentry.api.serializers.models.organization_access_request", - "sentry.api.serializers.models.organizationmemberinvite", - "sentry.api.serializers.models.orgauthtoken", "sentry.api.serializers.models.project", - "sentry.api.serializers.models.project_key", - "sentry.api.serializers.models.project_platform", "sentry.api.serializers.models.project_transaction_threshold", "sentry.api.serializers.models.projectcodeowners", "sentry.api.serializers.models.projectownership", "sentry.api.serializers.models.pullrequest", - "sentry.api.serializers.models.recentsearch", - "sentry.api.serializers.models.relayusage", "sentry.api.serializers.models.release", "sentry.api.serializers.models.release_file", - "sentry.api.serializers.models.release_threshold", "sentry.api.serializers.models.repository", "sentry.api.serializers.models.role", "sentry.api.serializers.models.seer_agentic_triage_run", "sentry.api.serializers.models.tagvalue", - "sentry.api.serializers.models.team", - "sentry.api.serializers.models.user_social_auth", "sentry.api.serializers.models.userreport", "sentry.api.serializers.models.userrollback", - "sentry.api.serializers.release_details_types", "sentry.api.serializers.rest_framework", "sentry.api.serializers.rest_framework.base", "sentry.api.serializers.rest_framework.commit", @@ -650,25 +599,21 @@ module = [ "sentry.api.serializers.rest_framework.environment", "sentry.api.serializers.rest_framework.groupsearchview", "sentry.api.serializers.rest_framework.list", - "sentry.api.serializers.rest_framework.origin", "sentry.api.serializers.rest_framework.project", "sentry.api.serializers.rest_framework.project_key", "sentry.api.serializers.rest_framework.release", "sentry.api.serializers.snuba", - "sentry.api.serializers.types", "sentry.api.urls", "sentry.api.utils", "sentry.api.validators.*", "sentry.apidocs", "sentry.apidocs.api_ownership_allowlist_dont_modify", "sentry.apidocs.build", - "sentry.apidocs.constants", "sentry.apidocs.extensions", "sentry.apidocs.hooks", "sentry.apidocs.parameters", "sentry.apidocs.schema", "sentry.apidocs.spectacular_ports", - "sentry.apidocs.utils", "sentry.assistant.*", "sentry.attachments.*", "sentry.audit_log", @@ -686,7 +631,6 @@ module = [ "sentry.culprit", "sentry.db", "sentry.db.analyze", - "sentry.db.exceptions", "sentry.db.models", "sentry.db.models.base", "sentry.db.models.fields", @@ -696,18 +640,15 @@ module = [ "sentry.db.models.fields.gzippeddict", "sentry.db.models.fields.jsonfield", "sentry.db.models.fields.node", - "sentry.db.models.fields.onetoone", "sentry.db.models.fields.slug", "sentry.db.models.fields.text", "sentry.db.models.fields.uuid", - "sentry.db.models.indexes", "sentry.db.models.query", "sentry.db.postgres.*", "sentry.db.router", "sentry.debug.*", "sentry.debug_files.*", "sentry.discover", - "sentry.discover.apps", "sentry.discover.arithmetic", "sentry.discover.compare_tables", "sentry.discover.dashboard_widget_split", @@ -716,7 +657,6 @@ module = [ "sentry.discover.models", "sentry.discover.translation.*", "sentry.event_manager", - "sentry.exceptions", "sentry.explore", "sentry.explore.endpoints.*", "sentry.explore.models", @@ -729,14 +669,13 @@ module = [ "sentry.feedback.lib.*", "sentry.feedback.usecases.*", "sentry.flags", - "sentry.flags.endpoints.*", + "sentry.flags.endpoints.logs", + "sentry.flags.endpoints.secrets", "sentry.flags.models", "sentry.flags.providers", "sentry.grouping", "sentry.grouping.context", "sentry.grouping.enhancer", - "sentry.grouping.enhancer.actions", - "sentry.grouping.enhancer.exceptions", "sentry.grouping.enhancer.matchers", "sentry.grouping.enhancer.rules", "sentry.grouping.grouptype", @@ -759,18 +698,14 @@ module = [ "sentry.ingest.consumer.*", "sentry.ingest.inbound_filters", "sentry.ingest.transaction_clusterer.*", - "sentry.ingest.types", - "sentry.ingest.userreport", "sentry.insights", "sentry.insights.endpoints.*", "sentry.insights.models", "sentry.integrations", - "sentry.integrations.analytics", "sentry.integrations.api.bases.*", "sentry.integrations.api.endpoints", "sentry.integrations.api.endpoints.data_forwarding_details", "sentry.integrations.api.endpoints.data_forwarding_index", - "sentry.integrations.api.endpoints.doc_integration_avatar", "sentry.integrations.api.endpoints.doc_integration_details", "sentry.integrations.api.endpoints.doc_integrations_index", "sentry.integrations.api.endpoints.external_team_details", @@ -805,7 +740,6 @@ module = [ "sentry.integrations.bitbucket.*", "sentry.integrations.bitbucket_server.*", "sentry.integrations.claude_code.*", - "sentry.integrations.client", "sentry.integrations.cursor.*", "sentry.integrations.data_forwarding.*", "sentry.integrations.discord", @@ -816,12 +750,10 @@ module = [ "sentry.integrations.discord.spec", "sentry.integrations.discord.types", "sentry.integrations.discord.urls", - "sentry.integrations.errors", "sentry.integrations.example.*", "sentry.integrations.github", "sentry.integrations.github.blame", "sentry.integrations.github.client", - "sentry.integrations.github.constants", "sentry.integrations.github.installation", "sentry.integrations.github.integration", "sentry.integrations.github.issue_sync", @@ -829,7 +761,6 @@ module = [ "sentry.integrations.github.search", "sentry.integrations.github.status_check", "sentry.integrations.github.tasks.*", - "sentry.integrations.github.types", "sentry.integrations.github.urls", "sentry.integrations.github.utils", "sentry.integrations.github.webhook_types", @@ -878,7 +809,6 @@ module = [ "sentry.integrations.msteams.analytics", "sentry.integrations.msteams.card_builder.*", "sentry.integrations.msteams.client", - "sentry.integrations.msteams.constants", "sentry.integrations.msteams.integration", "sentry.integrations.msteams.link_identity", "sentry.integrations.msteams.linkage", @@ -898,7 +828,6 @@ module = [ "sentry.integrations.opsgenie.metrics", "sentry.integrations.opsgenie.utils", "sentry.integrations.pipeline", - "sentry.integrations.referrer_ids", "sentry.integrations.slack", "sentry.integrations.slack.actions.*", "sentry.integrations.slack.analytics", @@ -952,22 +881,18 @@ module = [ "sentry.issues.endpoints.group_tagkey_values", "sentry.issues.endpoints.group_tags", "sentry.issues.endpoints.group_user_reports", - "sentry.issues.endpoints.organization_codeowners_associations", "sentry.issues.endpoints.organization_event_details", "sentry.issues.endpoints.organization_group_search_view_details_starred", "sentry.issues.endpoints.organization_group_search_view_starred_order", - "sentry.issues.endpoints.organization_group_search_views_starred", "sentry.issues.endpoints.organization_issue_metrics", "sentry.issues.endpoints.organization_issue_timeseries", "sentry.issues.endpoints.organization_issues_count", "sentry.issues.endpoints.organization_issues_resolved_in_release", "sentry.issues.endpoints.organization_issues_with_supergroups", "sentry.issues.endpoints.project_codeowners_details", - "sentry.issues.endpoints.project_codeowners_index", "sentry.issues.endpoints.project_issues_resolved_in_release", "sentry.issues.endpoints.project_ownership", "sentry.issues.endpoints.project_performance_issue_settings", - "sentry.issues.endpoints.project_stacktrace_source_context", "sentry.issues.endpoints.project_user_issue", "sentry.issues.endpoints.serializers", "sentry.issues.escalating", @@ -997,9 +922,7 @@ module = [ "sentry.middleware.demo_mode_guard", "sentry.middleware.devtoolbar", "sentry.middleware.env", - "sentry.middleware.health", "sentry.middleware.locale", - "sentry.middleware.placeholder", "sentry.middleware.proxy", "sentry.middleware.security", "sentry.middleware.staff", @@ -1037,7 +960,6 @@ module = [ "sentry.models.environment", "sentry.models.eventerror", "sentry.models.featureadoption", - "sentry.models.file", "sentry.models.files.*", "sentry.models.group", "sentry.models.groupbookmark", @@ -1063,11 +985,9 @@ module = [ "sentry.models.grouptombstone", "sentry.models.latestreporeleaseenvironment", "sentry.models.organization", - "sentry.models.organizationaccessrequest", "sentry.models.organizationcontributors", "sentry.models.organizationmember", "sentry.models.organizationmemberinvite", - "sentry.models.organizationmemberteam", "sentry.models.organizationonboardingtask", "sentry.models.orgauthtoken", "sentry.models.owner_base", @@ -1076,7 +996,6 @@ module = [ "sentry.models.projectcodeowners", "sentry.models.projectkey", "sentry.models.projectownership", - "sentry.models.projectplatform", "sentry.models.projectredirect", "sentry.models.projectsdk", "sentry.models.projectteam", @@ -1198,14 +1117,12 @@ module = [ "sentry.scm.*", "sentry.sdk_updates", "sentry.search", - "sentry.search.base", "sentry.search.events", "sentry.search.events.builder.*", "sentry.search.events.constants", "sentry.search.events.datasets.*", "sentry.search.events.fields", "sentry.search.events.types", - "sentry.search.exceptions", "sentry.search.snuba", "sentry.search.snuba.executors", "sentry.search.utils", @@ -1287,7 +1204,6 @@ module = [ "sentry.tasks.auto_source_code_config", "sentry.tasks.base", "sentry.tasks.clear_expired_resolutions", - "sentry.tasks.clear_expired_snoozes", "sentry.tasks.collect_project_platforms", "sentry.tasks.console_platform_cleanup", "sentry.tasks.delete_pending_groups", @@ -1320,7 +1236,6 @@ module = [ "sentry.tasks.web_vitals_issue_detection", "sentry.tasks.weekly_escalating_forecast", "sentry.tempest", - "sentry.tempest.apps", "sentry.tempest.models", "sentry.tempest.permissions", "sentry.tempest.serializers", @@ -1338,11 +1253,9 @@ module = [ "sentry.testutils.helpers", "sentry.testutils.helpers.analytics", "sentry.testutils.helpers.apigateway", - "sentry.testutils.helpers.auth_header", "sentry.testutils.helpers.backups", "sentry.testutils.helpers.data_blobs", "sentry.testutils.helpers.datetime", - "sentry.testutils.helpers.discover", "sentry.testutils.helpers.eventprocessing", "sentry.testutils.helpers.features", "sentry.testutils.helpers.impersonation", @@ -1360,7 +1273,6 @@ module = [ "sentry.testutils.helpers.uptime", "sentry.testutils.hybrid_cloud", "sentry.testutils.issue_detection.*", - "sentry.testutils.middleware", "sentry.testutils.outbox", "sentry.testutils.pytest", "sentry.testutils.pytest.fixtures", @@ -1398,7 +1310,6 @@ module = [ "sentry.utils.auth", "sentry.utils.avatar", "sentry.utils.batching_kafka_consumer", - "sentry.utils.cache", "sentry.utils.circuit_breaker", "sentry.utils.circuit_breaker2", "sentry.utils.codecs", @@ -1410,24 +1321,18 @@ module = [ "sentry.utils.datastructures", "sentry.utils.dates", "sentry.utils.db", - "sentry.utils.display_name_filter", "sentry.utils.eap", "sentry.utils.exceptions", - "sentry.utils.flag", - "sentry.utils.glob", "sentry.utils.groupreference", - "sentry.utils.hashlib", "sentry.utils.http", "sentry.utils.in_app", "sentry.utils.integrationdocs", "sentry.utils.json", "sentry.utils.jwt", - "sentry.utils.loremipsum", "sentry.utils.managers", "sentry.utils.marketo_client", "sentry.utils.meta", "sentry.utils.mockdata.*", - "sentry.utils.not_set", "sentry.utils.options", "sentry.utils.outcomes", "sentry.utils.pagination_factory", @@ -1443,7 +1348,6 @@ module = [ "sentry.utils.settings", "sentry.utils.snuba", "sentry.utils.strings", - "sentry.utils.tag_normalization", "sentry.utils.time_window", "sentry.utils.types", "sentry.utils.validators", @@ -1452,7 +1356,6 @@ module = [ "sentry.web", "sentry.web.api", "sentry.web.client_config", - "sentry.web.constants", "sentry.web.debug_urls", "sentry.web.decorators", "sentry.web.forms.*", @@ -1464,10 +1367,7 @@ module = [ "sentry.web.frontend.auth_logout", "sentry.web.frontend.auth_organization_login", "sentry.web.frontend.base", - "sentry.web.frontend.csrf_failure", "sentry.web.frontend.disabled_member_view", - "sentry.web.frontend.error_404", - "sentry.web.frontend.error_500", "sentry.web.frontend.generic", "sentry.web.frontend.group_event_json", "sentry.web.frontend.group_tag_export", @@ -1482,7 +1382,6 @@ module = [ "sentry.web.frontend.organization_avatar", "sentry.web.frontend.out", "sentry.web.frontend.pipeline_advancer", - "sentry.web.frontend.project_event", "sentry.web.frontend.react_page", "sentry.web.frontend.reactivate_account", "sentry.web.frontend.release_webhook", diff --git a/src/bitfield/models.py b/src/bitfield/models.py index 43f5204b11db..14670bb67c8b 100644 --- a/src/bitfield/models.py +++ b/src/bitfield/models.py @@ -1,5 +1,5 @@ -from collections.abc import Mapping, Sequence -from typing import Any, TypeVar, cast +from collections.abc import Iterator, Mapping, Sequence +from typing import Any, SupportsIndex, SupportsInt, TypeVar, cast from django.db.models import Model from django.db.models.fields import BigIntegerField @@ -13,7 +13,7 @@ class BitFieldFlags: - def __init__(self, flags): + def __init__(self, flags: Sequence[str]) -> None: if len(flags) > MAX_FLAG_COUNT: raise ValueError("Too many flags") self._flags = flags @@ -21,34 +21,34 @@ def __init__(self, flags): def __repr__(self) -> str: return repr(self._flags) - def __iter__(self): + def __iter__(self) -> Iterator[str]: yield from self._flags - def __getattr__(self, key): + def __getattr__(self, key: str) -> Bit: if key not in self._flags: raise AttributeError return Bit(self._flags.index(key)) __getitem__ = __getattr__ - def iteritems(self): + def iteritems(self) -> Iterator[tuple[str, Bit]]: for flag in self._flags: yield flag, Bit(self._flags.index(flag)) - def iterkeys(self): + def iterkeys(self) -> Iterator[str]: yield from self._flags - def itervalues(self): + def itervalues(self) -> Iterator[Bit]: for flag in self._flags: yield Bit(self._flags.index(flag)) - def items(self): + def items(self) -> list[tuple[str, Bit]]: return list(self.iteritems()) - def keys(self): + def keys(self) -> list[str]: return list(self.iterkeys()) - def values(self): + def values(self) -> list[Bit]: return list(self.itervalues()) @@ -61,10 +61,10 @@ class BitFieldCreator: available (usually during deploys). """ - def __init__(self, field): + def __init__(self, field: "BitField") -> None: self.field = field - def __set__(self, obj, value): + def __set__(self, obj: Model, value: object) -> None: obj.__dict__[self.field.name] = self.field.to_python(value) def __get__(self, obj, type=None): @@ -78,6 +78,9 @@ def __get__(self, obj, type=None): class BitField(BigIntegerField): + flags: list[str] + labels: list[str] + def contribute_to_class(self, cls: type[Model], name: str, private_only: bool = False) -> None: super().contribute_to_class(cls, name, private_only=private_only) setattr(cls, self.name, BitFieldCreator(self)) @@ -98,6 +101,7 @@ def __init__(self, flags, default=None, *args, **kwargs): self._arg_flags = flags flags = list(flags) + labels = [] for num, flag in enumerate(flags): if isinstance(flag, (tuple, list)): @@ -117,11 +121,14 @@ def __init__(self, flags, default=None, *args, **kwargs): self.flags = flags self.labels = labels - def pre_save(self, instance, add): + def pre_save(self, instance: Model, add: bool) -> object: value = getattr(instance, self.attname) return value - def get_prep_value(self, value): + def get_prep_value( + self, + value: SupportsInt | SupportsIndex | str | bytes | bytearray | Bit | BitHandler | None, + ) -> int | None: if value is None: return None if isinstance(value, (BitHandler, Bit)): @@ -153,7 +160,7 @@ def deconstruct(self): return name, path, args, kwargs -def flags_from_annotations(annotations: Mapping[str, type]) -> Sequence[str]: +def flags_from_annotations(annotations: Mapping[str, object]) -> list[str]: flags = [] for attr, ty in annotations.items(): assert ty in ("bool", bool), f"bitfields can only hold bools, {attr} is {ty!r}" @@ -203,7 +210,7 @@ class TypedClassBitField(metaclass=TypedBitfieldMeta): T = TypeVar("T") -def typed_dict_bitfield(definition: type[T], default=None, null=False) -> T: +def typed_dict_bitfield(definition: type[T], default: object = None, null: bool = False) -> T: """ A wrapper around BitField that allows you to access its fields as dictionary keys attributes in a type-safe way. diff --git a/src/bitfield/query.py b/src/bitfield/query.py index 3fa215f30441..32b2eebaca25 100644 --- a/src/bitfield/query.py +++ b/src/bitfield/query.py @@ -9,7 +9,7 @@ def get_db_prep_lookup(self, value, connection, prepared=False): raise NotImplementedError("get_db_prep_lookup not supported for Bit, BitHandler") return super().get_db_prep_lookup(value, connection) - def get_prep_lookup(self): + def get_prep_lookup(self) -> object: if isinstance(self.rhs, (Bit,)): raise NotImplementedError("get_db_prep_lookup not supported for Bit") return self.rhs diff --git a/src/bitfield/types.py b/src/bitfield/types.py index 5cc30ec5eedc..0abba207d626 100644 --- a/src/bitfield/types.py +++ b/src/bitfield/types.py @@ -1,23 +1,43 @@ -def cmp(a, b): +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Protocol, SupportsIndex, SupportsInt, TypeVar + +_IntConvertible = SupportsInt | SupportsIndex | str | bytes | bytearray | float + + +def cmp(a: int, b: int) -> int: return (a > b) - (a < b) +_TPrepared = TypeVar("_TPrepared", covariant=True) + + +class _BitEvaluator(Protocol[_TPrepared]): + def prepare_node(self, node: Bit, query: object, allow_joins: bool) -> _TPrepared: ... + + class Bit: - def __init__(self, number, is_set=True): + number: int + is_set: bool + mask: int + children: list[Bit] + + def __init__(self, number: int, is_set: object = True) -> None: self.number = number self.is_set = bool(is_set) self.mask = 2 ** int(number) - self.children = [] + self.children: list[Bit] = [] if not self.is_set: self.mask = ~self.mask def __repr__(self) -> str: return "<%s: number=%d, is_set=%s>" % (self.__class__.__name__, self.number, self.is_set) - def __int__(self): + def __int__(self) -> int: return self.mask - def __bool__(self): + def __bool__(self) -> bool: return self.is_set def __eq__(self, value): @@ -29,13 +49,13 @@ def __eq__(self, value): return value == self.mask return value == self.is_set - def __ne__(self, value): + def __ne__(self, value: object) -> bool: return not self == value - def __coerce__(self, value): + def __coerce__(self, value: object) -> tuple[bool, bool]: return (self.is_set, bool(value)) - def __invert__(self): + def __invert__(self) -> Bit: return self.__class__(self.number, not self.is_set) def __and__(self, value): @@ -88,13 +108,17 @@ def __rxor__(self, value): value = value.mask return self.mask ^ value - def __sentry__(self): + def __sentry__(self) -> str: return repr(self) - def evaluate(self, evaluator, qn, connection): + def evaluate( + self, evaluator: object, qn: object, connection: object + ) -> tuple[int, list[object]]: return self.mask, [] - def prepare(self, evaluator, query, allow_joins): + def prepare( + self, evaluator: _BitEvaluator[_TPrepared], query: object, allow_joins: bool + ) -> _TPrepared: return evaluator.prepare_node(self, query, allow_joins) @@ -103,7 +127,16 @@ class BitHandler: Represents an array of bits, each as a ``Bit`` object. """ - def __init__(self, value, keys, labels=None): + _value: int + _keys: Sequence[str] + _labels: Sequence[object] + + def __init__( + self, + value: _IntConvertible | None, + keys: Sequence[str], + labels: Sequence[object] | None = None, + ) -> None: # TODO: change to bitarray? if value: self._value = int(value) @@ -112,7 +145,7 @@ def __init__(self, value, keys, labels=None): self._keys = keys self._labels = labels is not None and labels or keys - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, BitHandler): return False return self._value == other._value @@ -141,34 +174,34 @@ def __repr__(self) -> str: def __str__(self) -> str: return str(self._value) - def __int__(self): + def __int__(self) -> int: return self._value - def __bool__(self): + def __bool__(self) -> bool: return bool(self._value) - def __and__(self, value): + def __and__(self, value: _IntConvertible) -> BitHandler: return BitHandler(self._value & int(value), self._keys) - def __or__(self, value): + def __or__(self, value: _IntConvertible) -> BitHandler: return BitHandler(self._value | int(value), self._keys) - def __add__(self, value): + def __add__(self, value: _IntConvertible) -> BitHandler: return BitHandler(self._value + int(value), self._keys) - def __sub__(self, value): + def __sub__(self, value: _IntConvertible) -> BitHandler: return BitHandler(self._value - int(value), self._keys) - def __lshift__(self, value): + def __lshift__(self, value: _IntConvertible) -> BitHandler: return BitHandler(self._value << int(value), self._keys) - def __rshift__(self, value): + def __rshift__(self, value: _IntConvertible) -> BitHandler: return BitHandler(self._value >> int(value), self._keys) - def __xor__(self, value): + def __xor__(self, value: _IntConvertible) -> BitHandler: return BitHandler(self._value ^ int(value), self._keys) - def __contains__(self, key): + def __contains__(self, key: str) -> bool: bit_number = self._keys.index(key) return bool(self.get_bit(bit_number)) @@ -181,7 +214,7 @@ def __getattr__(self, key): __getitem__ = __getattr__ - def __setattr__(self, key, value) -> None: + def __setattr__(self, key: str, value: object) -> None: if key.startswith("_"): return object.__setattr__(self, key, value) if key not in self._keys: @@ -190,25 +223,27 @@ def __setattr__(self, key, value) -> None: __setitem__ = __setattr__ - def __iter__(self): + def __iter__(self) -> Iterator[tuple[str, bool]]: return self.iteritems() - def __sentry__(self): + def __sentry__(self) -> str: return repr(self) - def _get_mask(self): + def _get_mask(self) -> int: return self._value mask = property(_get_mask) - def evaluate(self, evaluator, qn, connection): + def evaluate( + self, evaluator: object, qn: object, connection: object + ) -> tuple[int, list[object]]: return self.mask, [] - def get_bit(self, bit_number): + def get_bit(self, bit_number: int) -> Bit: mask = 2 ** int(bit_number) return Bit(bit_number, self._value & mask != 0) - def set_bit(self, bit_number, true_or_false): + def set_bit(self, bit_number: int, true_or_false: object) -> Bit: mask = 2 ** int(bit_number) if true_or_false: self._value |= mask @@ -216,20 +251,20 @@ def set_bit(self, bit_number, true_or_false): self._value &= ~mask return Bit(bit_number, self._value & mask != 0) - def keys(self): + def keys(self) -> Sequence[str]: return self._keys - def iterkeys(self): + def iterkeys(self) -> Iterator[str]: return iter(self._keys) - def items(self): + def items(self) -> list[tuple[str, bool]]: return list(self.iteritems()) - def iteritems(self): + def iteritems(self) -> Iterator[tuple[str, bool]]: for k in self._keys: yield (k, getattr(self, k).is_set) - def get_label(self, flag): + def get_label(self, flag: str | Bit | int) -> object: if isinstance(flag, str): flag = self._keys.index(flag) if isinstance(flag, Bit): diff --git a/src/sentry/api/bases/avatar.py b/src/sentry/api/bases/avatar.py index 34dbcc7e51b4..43492cbf05e0 100644 --- a/src/sentry/api/bases/avatar.py +++ b/src/sentry/api/bases/avatar.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, ClassVar, Generic, TypeVar +from typing import Any, ClassVar, Generic, TypeVar, cast from rest_framework import serializers, status from rest_framework.request import Request @@ -15,6 +15,9 @@ AvatarT = TypeVar("AvatarT", bound=AvatarBase) +AvatarObjectT = TypeVar("AvatarObjectT", bound=Model) + + class AvatarSerializer(serializers.Serializer[dict[str, Any]]): avatar_photo = AvatarField(required=False) avatar_type = serializers.ChoiceField( @@ -45,7 +48,7 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: return attrs -class AvatarMixin(Generic[AvatarT]): +class AvatarMixin(Generic[AvatarT, AvatarObjectT]): object_type: ClassVar[str] serializer_cls: ClassVar[type[serializers.Serializer[dict[str, Any]]]] = AvatarSerializer @@ -57,15 +60,15 @@ def get(self, request: Request, **kwargs: Any) -> Response: obj = kwargs.pop(self.object_type, None) return Response(serialize(obj, request.user, **kwargs)) - def get_serializer_context(self, obj: Model, **kwargs: Any) -> dict[str, Any]: + def get_serializer_context(self, obj: AvatarObjectT, **kwargs: Any) -> dict[str, Any]: return {"type": self.model, "kwargs": {self.object_type: obj}} - def get_avatar_filename(self, obj: Model) -> str: + def get_avatar_filename(self, obj: AvatarObjectT) -> str: return f"{obj.id}.png" def parse( self, request: Request, **kwargs: Any - ) -> tuple[Model, serializers.Serializer[dict[str, Any]]]: + ) -> tuple[AvatarObjectT, serializers.Serializer[dict[str, Any]]]: obj = kwargs.pop(self.object_type, None) serializer = self.serializer_cls( @@ -74,7 +77,10 @@ def parse( return (obj, serializer) def save_avatar( - self, obj: Model, serializer: serializers.Serializer[dict[str, Any]], **kwargs: Any + self, + obj: AvatarObjectT, + serializer: serializers.Serializer[dict[str, Any]], + **kwargs: Any, ) -> AvatarT: result = serializer.validated_data @@ -86,10 +92,12 @@ def save_avatar( color=result.get("color"), ) - def put(self, request: Request, **kwargs: Any) -> Response: + def put(self, request: Request, **kwargs: Any) -> Response[dict[str, object]]: obj, serializer = self.parse(request, **kwargs) if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + cast(dict[str, object], serializer.errors), status=status.HTTP_400_BAD_REQUEST + ) self.save_avatar(obj, serializer) obj = kwargs.pop(self.object_type, None) # serialize doesn't like these params - return Response(serialize(obj, request.user, **kwargs)) + return Response(cast(dict[str, object], serialize(obj, request.user, **kwargs))) diff --git a/src/sentry/api/endpoints/api_application_details.py b/src/sentry/api/endpoints/api_application_details.py index 8b83edaff192..4fc4329f7ec9 100644 --- a/src/sentry/api/endpoints/api_application_details.py +++ b/src/sentry/api/endpoints/api_application_details.py @@ -38,9 +38,9 @@ def convert_args( self, request: Request, app_id: str, - *args, - **kwargs, - ): + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: try: application = ApiApplication.objects.get( owner_id=request.user.id, client_id=app_id, status=ApiApplicationStatus.active diff --git a/src/sentry/api/endpoints/api_token_details.py b/src/sentry/api/endpoints/api_token_details.py index f94439c2e143..00cbe3556540 100644 --- a/src/sentry/api/endpoints/api_token_details.py +++ b/src/sentry/api/endpoints/api_token_details.py @@ -44,7 +44,13 @@ class ApiTokenDetailsEndpoint(Endpoint): DisallowImpersonatedTokenCreation, ) - def convert_args(self, request: Request, token_id: str, *args, **kwargs): + def convert_args( + self, + request: Request, + token_id: str, + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: validated_token_id = to_valid_int_id("token_id", token_id, raise_404=True) user_id = get_appropriate_user_id(request=request) try: diff --git a/src/sentry/api/endpoints/api_tokens.py b/src/sentry/api/endpoints/api_tokens.py index f7ed17afdcd8..ece1750efe4d 100644 --- a/src/sentry/api/endpoints/api_tokens.py +++ b/src/sentry/api/endpoints/api_tokens.py @@ -109,7 +109,7 @@ def post(self, request: Request) -> Response: return Response(serializer.errors, status=400) @method_decorator(never_cache) - def delete(self, request: Request): + def delete(self, request: Request) -> Response[dict[str, object] | None]: user_id = get_appropriate_user_id(request=request) token_id = request.data.get("tokenId", None) diff --git a/src/sentry/api/endpoints/broadcast_details.py b/src/sentry/api/endpoints/broadcast_details.py index 7bc745a440e6..2ccd39d96ff5 100644 --- a/src/sentry/api/endpoints/broadcast_details.py +++ b/src/sentry/api/endpoints/broadcast_details.py @@ -39,9 +39,9 @@ def convert_args( self, request: Request, broadcast_id: int | str, - *args, - **kwargs, - ): + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: if request.access.has_permission("broadcasts.admin"): queryset = Broadcast.objects.all() else: diff --git a/src/sentry/api/endpoints/catchall.py b/src/sentry/api/endpoints/catchall.py index 683678baed2a..65202b83a718 100644 --- a/src/sentry/api/endpoints/catchall.py +++ b/src/sentry/api/endpoints/catchall.py @@ -11,7 +11,7 @@ class CatchallEndpoint(Endpoint): @csrf_exempt @allow_cors_options - def dispatch(self, request: Request, *args, **kwargs) -> HttpResponse: + def dispatch(self, request: Request, *args: object, **kwargs: object) -> HttpResponse: """ This endpoint handles routes that did not match """ diff --git a/src/sentry/api/endpoints/email_capture.py b/src/sentry/api/endpoints/email_capture.py index bcff0d093725..bf7b746e8d69 100644 --- a/src/sentry/api/endpoints/email_capture.py +++ b/src/sentry/api/endpoints/email_capture.py @@ -13,7 +13,7 @@ client = MarketoClient() -class EmailCaptureSerializer(CamelSnakeSerializer): +class EmailCaptureSerializer(CamelSnakeSerializer[dict[str, str]]): email = serializers.EmailField(required=True) diff --git a/src/sentry/api/endpoints/event_apple_crash_report.py b/src/sentry/api/endpoints/event_apple_crash_report.py index 8e4c409f200d..bb57bff97961 100644 --- a/src/sentry/api/endpoints/event_apple_crash_report.py +++ b/src/sentry/api/endpoints/event_apple_crash_report.py @@ -9,6 +9,7 @@ from sentry.api.bases.project import ProjectEndpoint from sentry.api.exceptions import ResourceDoesNotExist from sentry.lang.native.applecrashreport import AppleCrashReport +from sentry.models.project import Project from sentry.services import eventstore from sentry.utils.safe import get_path @@ -20,7 +21,7 @@ class EventAppleCrashReportEndpoint(ProjectEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, project, event_id) -> HttpResponseBase: + def get(self, request: Request, project: Project, event_id: str) -> HttpResponseBase: """ Retrieve an Apple Crash Report from an event ````````````````````````````````````````````` diff --git a/src/sentry/api/endpoints/event_attachments.py b/src/sentry/api/endpoints/event_attachments.py index bce955a29274..9c910dfff18e 100644 --- a/src/sentry/api/endpoints/event_attachments.py +++ b/src/sentry/api/endpoints/event_attachments.py @@ -16,6 +16,7 @@ from sentry.apidocs.parameters import CursorQueryParam, EventParams, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.eventattachment import EventAttachment, event_attachment_screenshot_filter +from sentry.models.project import Project from sentry.search.utils import tokenize_query from sentry.services import eventstore @@ -60,7 +61,7 @@ class EventAttachmentsEndpoint(ProjectEndpoint): examples=EventAttachmentExamples.LIST_EVENT_ATTACHMENTS, ) def get( - self, request: Request, project, event_id + self, request: Request, project: Project, event_id: str ) -> Response[list[EventAttachmentSerializerResponse]]: """ Retrieve a list of attachments uploaded for a given event. diff --git a/src/sentry/api/endpoints/event_file_committers.py b/src/sentry/api/endpoints/event_file_committers.py index f14cb34dc1c0..59aba62ec82b 100644 --- a/src/sentry/api/endpoints/event_file_committers.py +++ b/src/sentry/api/endpoints/event_file_committers.py @@ -7,6 +7,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint +from sentry.models.project import Project from sentry.services import eventstore from sentry.utils.committers import get_serialized_event_file_committers @@ -18,7 +19,7 @@ class EventFileCommittersEndpoint(ProjectEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, project, event_id) -> Response: + def get(self, request: Request, project: Project, event_id: str) -> Response: """ Retrieve Suspect Commit information for an event ``````````````````````````````````````````` diff --git a/src/sentry/api/endpoints/filechange.py b/src/sentry/api/endpoints/filechange.py index 693d1362f677..205c1f9564ba 100644 --- a/src/sentry/api/endpoints/filechange.py +++ b/src/sentry/api/endpoints/filechange.py @@ -11,6 +11,7 @@ from sentry.constants import ObjectStatus from sentry.models.commit import Commit from sentry.models.commitfilechange import CommitFileChange +from sentry.models.organization import Organization from sentry.models.release import Release from sentry.models.releasecommit import ReleaseCommit from sentry.models.repository import Repository @@ -23,7 +24,7 @@ class CommitFileChangeEndpoint(OrganizationReleasesBaseEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, organization, version) -> Response: + def get(self, request: Request, organization: Organization, version: str) -> Response: """ Retrieve Files Changed in a Release's Commits ````````````````````````````````````````````` diff --git a/src/sentry/api/endpoints/internal/beacon.py b/src/sentry/api/endpoints/internal/beacon.py index e232d242ed91..e69ccd0ae9cc 100644 --- a/src/sentry/api/endpoints/internal/beacon.py +++ b/src/sentry/api/endpoints/internal/beacon.py @@ -1,4 +1,5 @@ import logging +from typing import TypedDict from rest_framework import serializers, status from rest_framework.request import Request @@ -11,29 +12,30 @@ logger = logging.getLogger(__name__) -# These is an arbitrarily picked limit for both the # of batched metrics supported, -# as well as the size of the dict for each metric -MAX_LENGTH = 20 +MAX_BATCH_METRIC_COUNT = 20 +MAX_FIELDS_PER_METRIC = 20 -class MetricsSerializer(serializers.Serializer): +class _MetricsData(TypedDict): + batch_data: list[dict[str, str]] + + +class MetricsSerializer(serializers.Serializer[None, _MetricsData]): batch_data = serializers.ListField( child=serializers.DictField( - # This is intentionally a bit restrictive to limit the size of payloads (and abuse) - # These metrics should not be sending complex payloads anyway child=serializers.CharField(max_length=1024), allow_empty=False, ), - max_length=MAX_LENGTH, + max_length=MAX_BATCH_METRIC_COUNT, ) - def validate(self, attrs): + def validate(self, attrs: _MetricsData) -> _MetricsData: attrs = super().validate(attrs) - for metric in attrs.get("batch_data"): - if len(metric) > MAX_LENGTH: + for metric in attrs["batch_data"]: + if len(metric) > MAX_FIELDS_PER_METRIC: raise serializers.ValidationError( - {"batch_data": f"Dict size must be less than {MAX_LENGTH}"} + {"batch_data": f"Dict size must be less than {MAX_FIELDS_PER_METRIC}"} ) return attrs diff --git a/src/sentry/api/endpoints/organization_access_request_details.py b/src/sentry/api/endpoints/organization_access_request_details.py index 87262b5ed286..3ba6455a1671 100644 --- a/src/sentry/api/endpoints/organization_access_request_details.py +++ b/src/sentry/api/endpoints/organization_access_request_details.py @@ -61,7 +61,7 @@ class OrganizationAccessRequestDetailsEndpoint(OrganizationEndpoint): permission_classes = (AccessRequestPermission,) # TODO(dcramer): this should go onto AccessRequestPermission - def _can_access(self, request: Request, access_request): + def _can_access(self, request: Request, access_request: OrganizationAccessRequest) -> bool: if request.access.has_scope("org:admin"): return True if request.access.has_scope("org:write"): diff --git a/src/sentry/api/endpoints/organization_api_key_details.py b/src/sentry/api/endpoints/organization_api_key_details.py index a89d0eebb1e0..a272fd457d65 100644 --- a/src/sentry/api/endpoints/organization_api_key_details.py +++ b/src/sentry/api/endpoints/organization_api_key_details.py @@ -25,7 +25,7 @@ class Meta: model = ApiKey fields = ("label", "scope_list", "allowed_origins") - def validate_scope_list(self, value): + def validate_scope_list(self, value: list[str]) -> list[str]: invalid_scopes = set(value) - SENTRY_SCOPES if invalid_scopes: raise serializers.ValidationError( diff --git a/src/sentry/api/endpoints/organization_api_key_index.py b/src/sentry/api/endpoints/organization_api_key_index.py index c6fc4d45cbd6..30b912f0422a 100644 --- a/src/sentry/api/endpoints/organization_api_key_index.py +++ b/src/sentry/api/endpoints/organization_api_key_index.py @@ -12,6 +12,10 @@ ) from sentry.api.serializers import serialize from sentry.models.apikey import ApiKey +from sentry.organizations.services.organization.model import ( + RpcOrganization, + RpcUserOrganizationContext, +) DEFAULT_SCOPES = ["project:read", "event:read", "team:read", "org:read", "member:read"] @@ -25,7 +29,12 @@ class OrganizationApiKeyIndexEndpoint(ControlSiloOrganizationEndpoint): } permission_classes = (OrganizationAdminPermission,) - def get(self, request: Request, organization_context, organization) -> Response: + def get( + self, + request: Request, + organization_context: RpcUserOrganizationContext, + organization: RpcOrganization, + ) -> Response: """ List an Organization's API Keys ``````````````````````````````````` @@ -39,7 +48,12 @@ def get(self, request: Request, organization_context, organization) -> Response: return Response(serialize(queryset, request.user)) - def post(self, request: Request, organization_context, organization) -> Response: + def post( + self, + request: Request, + organization_context: RpcUserOrganizationContext, + organization: RpcOrganization, + ) -> Response: """ Create an Organization API Key ``````````````````````````````````` diff --git a/src/sentry/api/endpoints/organization_artifactbundle_assemble.py b/src/sentry/api/endpoints/organization_artifactbundle_assemble.py index ee55231a47da..6123302a8378 100644 --- a/src/sentry/api/endpoints/organization_artifactbundle_assemble.py +++ b/src/sentry/api/endpoints/organization_artifactbundle_assemble.py @@ -23,6 +23,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import ObjectStatus from sentry.debug_files.upload import find_missing_chunks +from sentry.models.organization import Organization from sentry.models.orgauthtoken import is_org_auth_token_auth, update_org_auth_token_last_used from sentry.models.project import Project from sentry.tasks.assemble import ( @@ -107,7 +108,7 @@ class OrganizationArtifactBundleAssembleEndpoint(OrganizationReleasesBaseEndpoin }, ) def post( - self, request: Request, organization + self, request: Request, organization: Organization ) -> Response[ArtifactBundleAssembleResponse] | Response[_AssembleErrorResponse]: """ Assemble previously uploaded chunks into an artifact bundle, such as source maps, and diff --git a/src/sentry/api/endpoints/organization_auth_token_details.py b/src/sentry/api/endpoints/organization_auth_token_details.py index e90e2d5285ee..0ba88e69a47b 100644 --- a/src/sentry/api/endpoints/organization_auth_token_details.py +++ b/src/sentry/api/endpoints/organization_auth_token_details.py @@ -57,17 +57,19 @@ def convert_args( def get( self, request: Request, + organization: RpcOrganization, instance: OrgAuthToken, - **kwargs, + **kwargs: object, ) -> Response: return Response(serialize(instance, request.user, token=None)) def put( self, request: Request, + organization: RpcOrganization, instance: OrgAuthToken, - **kwargs, - ): + **kwargs: object, + ) -> Response: name = request.data.get("name") if not name: @@ -87,8 +89,8 @@ def delete( request: Request, organization: RpcOrganization, instance: OrgAuthToken, - **kwargs, - ): + **kwargs: object, + ) -> Response: instance.update(date_deactivated=timezone.now()) self.create_audit_entry( diff --git a/src/sentry/api/endpoints/organization_fork.py b/src/sentry/api/endpoints/organization_fork.py index fac6fecc5af6..5803463688cf 100644 --- a/src/sentry/api/endpoints/organization_fork.py +++ b/src/sentry/api/endpoints/organization_fork.py @@ -53,7 +53,7 @@ class OrganizationForkEndpoint(Endpoint): } permission_classes = (SuperuserOrStaffFeatureFlaggedPermission,) - def post(self, request: Request, organization_id_or_slug) -> Response: + def post(self, request: Request, organization_id_or_slug: str) -> Response: """ Duplicate an organization across regions. The old organization remains untouched. We kick off the standard `SAAS_TO_SAAS` relocation flow to create a duplicate in this region. diff --git a/src/sentry/api/endpoints/organization_onboarding_tasks.py b/src/sentry/api/endpoints/organization_onboarding_tasks.py index 0f9a36cae464..890ecb618e66 100644 --- a/src/sentry/api/endpoints/organization_onboarding_tasks.py +++ b/src/sentry/api/endpoints/organization_onboarding_tasks.py @@ -26,7 +26,7 @@ class OrganizationOnboardingTaskEndpoint(OrganizationEndpoint): owner = ApiOwner.VALUE_DISCOVERY permission_classes = (OnboardingTaskPermission,) - def post(self, request: Request, organization) -> Response: + def post(self, request: Request, organization: Organization) -> Response: task_id = onboarding_tasks.get_task_lookup_by_key(request.data["task"]) if task_id is None: return Response({"detail": "Invalid task key"}, status=422) diff --git a/src/sentry/api/endpoints/organization_pipeline.py b/src/sentry/api/endpoints/organization_pipeline.py index 4a49058abd67..834e34fcbd74 100644 --- a/src/sentry/api/endpoints/organization_pipeline.py +++ b/src/sentry/api/endpoints/organization_pipeline.py @@ -20,7 +20,6 @@ initialize_integration_pipeline, ) from sentry.organizations.services.organization.model import RpcOrganization -from sentry.pipeline.base import Pipeline from sentry.pipeline.types import PipelineStepAction from sentry.utils import metrics @@ -40,7 +39,7 @@ class PipelinePermission(OrganizationPermission): def _get_api_pipeline( request: Request, organization: RpcOrganization, pipeline_name: str -) -> Response | Pipeline: +) -> Response[dict[str, str]] | IntegrationPipeline | IdentityPipeline: """Look up an active API-ready pipeline from the session, or return an error Response.""" pipelines = {cls.pipeline_name: cls for cls in PIPELINE_CLASSES} if pipeline_name not in pipelines: diff --git a/src/sentry/api/endpoints/organization_project_keys.py b/src/sentry/api/endpoints/organization_project_keys.py index 8eae59787274..87bb136f708a 100644 --- a/src/sentry/api/endpoints/organization_project_keys.py +++ b/src/sentry/api/endpoints/organization_project_keys.py @@ -15,6 +15,7 @@ from sentry.apidocs.examples.project_examples import KEY_NO_RATE_LIMIT, KEY_RATE_LIMIT from sentry.apidocs.parameters import CursorQueryParam, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.organization import Organization from sentry.models.project import Project from sentry.models.projectkey import ProjectKey, ProjectKeyStatus from sentry.models.team import Team @@ -81,7 +82,9 @@ class OrganizationProjectKeysEndpoint(OrganizationEndpoint): ), ], ) - def get(self, request: Request, organization) -> Response[list[ProjectKeySerializerResponse]]: + def get( + self, request: Request, organization: Organization + ) -> Response[list[ProjectKeySerializerResponse]]: """ Return a list of client keys (DSNs) for all projects in an organization. diff --git a/src/sentry/api/endpoints/organization_recent_searches.py b/src/sentry/api/endpoints/organization_recent_searches.py index 0f9e837e089c..4ddb1a5e2f7b 100644 --- a/src/sentry/api/endpoints/organization_recent_searches.py +++ b/src/sentry/api/endpoints/organization_recent_searches.py @@ -8,6 +8,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.api.serializers import serialize +from sentry.models.organization import Organization from sentry.models.recentsearch import RecentSearch, remove_excess_recent_searches from sentry.models.search_common import SearchType @@ -33,7 +34,7 @@ class OrganizationRecentSearchesEndpoint(OrganizationEndpoint): } permission_classes = (OrganizationRecentSearchPermission,) - def get(self, request: Request, organization) -> Response: + def get(self, request: Request, organization: Organization) -> Response: """ List recent searches for a User within an Organization `````````````````````````````````````````````````````` @@ -67,7 +68,7 @@ def get(self, request: Request, organization) -> Response: return Response(serialize(recent_searches, request.user)) - def post(self, request: Request, organization) -> Response: + def post(self, request: Request, organization: Organization) -> Response: serializer = RecentSearchSerializer(data=request.data) if serializer.is_valid(): diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index a69ada821ac3..0e11f5f57430 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from collections.abc import Iterable from datetime import datetime, timedelta from typing import Any, TypedDict, cast @@ -101,7 +102,7 @@ ERR_INVALID_STATS_PERIOD = "Invalid %s. Valid choices are %s" -def get_stats_period_detail(key, choices): +def get_stats_period_detail(key: str, choices: Iterable[str]) -> str: return ERR_INVALID_STATS_PERIOD % (key, ", ".join("'%s'" % x for x in choices)) @@ -243,7 +244,7 @@ class ReleaseSerializerWithProjects(ReleaseWithVersionSerializer): @traces.trace -def debounce_update_release_health_data(organization, project_ids: list[int]): +def debounce_update_release_health_data(organization: Organization, project_ids: list[int]) -> None: """This causes a flush of snuba health data to the postgres tables once per minute for the given projects. """ diff --git a/src/sentry/api/endpoints/organization_stats.py b/src/sentry/api/endpoints/organization_stats.py index a09b3f1694a7..e6e1b69bffcb 100644 --- a/src/sentry/api/endpoints/organization_stats.py +++ b/src/sentry/api/endpoints/organization_stats.py @@ -10,6 +10,7 @@ from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.helpers.environments import get_environment_id from sentry.models.environment import Environment +from sentry.models.organization import Organization from sentry.models.project import Project from sentry.models.team import Team from sentry.tsdb.base import TSDBModel @@ -23,7 +24,7 @@ class OrganizationStatsEndpoint(OrganizationEndpoint, StatsMixin): } owner = ApiOwner.FOUNDATIONS - def get(self, request: Request, organization) -> Response: + def get(self, request: Request, organization: Organization) -> Response: """ Retrieve Event Counts for an Organization ````````````````````````````````````````` diff --git a/src/sentry/api/endpoints/organization_tagkey_values.py b/src/sentry/api/endpoints/organization_tagkey_values.py index 81feca159b5a..019899d79745 100644 --- a/src/sentry/api/endpoints/organization_tagkey_values.py +++ b/src/sentry/api/endpoints/organization_tagkey_values.py @@ -20,6 +20,7 @@ from sentry.apidocs.parameters import GlobalParams, OrganizationParams from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.models.organization import Organization from sentry.snuba.dataset import Dataset from sentry.tagstore.base import TAG_KEY_RE from sentry.tagstore.types import TagValue, TagValueSerializerResponse @@ -98,7 +99,7 @@ class OrganizationTagKeyValuesEndpoint(OrganizationEventsEndpointBase): }, ) def get( - self, request: Request, organization, key + self, request: Request, organization: Organization, key: str ) -> Response[list[TagValueSerializerResponse]] | Response[DetailResponse]: """ Return a list of values associated with this tag key across the organization's diff --git a/src/sentry/api/endpoints/project_artifact_bundle_file_details.py b/src/sentry/api/endpoints/project_artifact_bundle_file_details.py index 349302ed0cdd..b9afdfb93d0d 100644 --- a/src/sentry/api/endpoints/project_artifact_bundle_file_details.py +++ b/src/sentry/api/endpoints/project_artifact_bundle_file_details.py @@ -14,6 +14,7 @@ from sentry.api.bases.project import ProjectEndpoint, ProjectReleasePermission from sentry.api.endpoints.debug_files import has_download_permission from sentry.models.artifactbundle import ArtifactBundle, ArtifactBundleArchive +from sentry.models.project import Project from sentry.releases.endpoints.project_release_file_details import ClosesDependentFiles @@ -56,7 +57,9 @@ class ProjectArtifactBundleFileDetailsEndpoint( } permission_classes = (ProjectReleasePermission,) - def get(self, request: Request, project, bundle_id, file_id) -> HttpResponseBase: + def get( + self, request: Request, project: Project, bundle_id: str, file_id: str + ) -> HttpResponseBase: """ Retrieve the file of an artifact bundle ````````````````````````````````` diff --git a/src/sentry/api/endpoints/project_commits.py b/src/sentry/api/endpoints/project_commits.py index 7a7fd23481f5..6e199932c232 100644 --- a/src/sentry/api/endpoints/project_commits.py +++ b/src/sentry/api/endpoints/project_commits.py @@ -8,6 +8,7 @@ from sentry.api.paginator import OffsetPaginator from sentry.api.serializers import serialize from sentry.models.commit import Commit +from sentry.models.project import Project @cell_silo_endpoint @@ -19,7 +20,7 @@ class ProjectCommitsEndpoint(ProjectEndpoint): permission_classes = (ProjectReleasePermission,) - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project: Project) -> Response: """ List a Project's Commits ````````````````````````` diff --git a/src/sentry/api/endpoints/project_filter_details.py b/src/sentry/api/endpoints/project_filter_details.py index c46e3a6370c7..6af86ac873de 100644 --- a/src/sentry/api/endpoints/project_filter_details.py +++ b/src/sentry/api/endpoints/project_filter_details.py @@ -19,6 +19,7 @@ from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors from sentry.ingest import inbound_filters from sentry.ingest.inbound_filters import FilterStatKeys, _LegacyBrowserFilterSerializer +from sentry.models.project import Project @extend_schema(tags=["Projects"]) @@ -46,7 +47,7 @@ class ProjectFilterDetailsEndpoint(ProjectEndpoint): examples=None, ) def put( - self, request: Request, project, filter_id + self, request: Request, project: Project, filter_id: str ) -> Response[None] | Response[ValidationErrorResponse]: """ Update various inbound data filters for a project. diff --git a/src/sentry/api/endpoints/project_filters.py b/src/sentry/api/endpoints/project_filters.py index c678db1232e7..3fda074f6fb8 100644 --- a/src/sentry/api/endpoints/project_filters.py +++ b/src/sentry/api/endpoints/project_filters.py @@ -13,6 +13,7 @@ from sentry.apidocs.parameters import GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.ingest import inbound_filters +from sentry.models.project import Project class ProjectFilterResponse(TypedDict): @@ -43,7 +44,7 @@ class ProjectFiltersEndpoint(ProjectEndpoint): }, examples=ProjectExamples.GET_PROJECT_FILTERS, ) - def get(self, request: Request, project) -> Response[list[ProjectFilterResponse]]: + def get(self, request: Request, project: Project) -> Response[list[ProjectFilterResponse]]: """ Retrieve a list of filters for a given project. `active` will be either a boolean or a list for the legacy browser filters. diff --git a/src/sentry/api/endpoints/project_member_index.py b/src/sentry/api/endpoints/project_member_index.py index 27a2e1121260..fd03bffbad68 100644 --- a/src/sentry/api/endpoints/project_member_index.py +++ b/src/sentry/api/endpoints/project_member_index.py @@ -14,6 +14,7 @@ from sentry.apidocs.parameters import GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.organizationmember import OrganizationMember +from sentry.models.project import Project @extend_schema(tags=["Projects"]) @@ -38,7 +39,7 @@ class ProjectMemberIndexEndpoint(ProjectEndpoint): }, examples=OrganizationMemberExamples.LIST_ORG_MEMBERS, ) - def get(self, request: Request, project) -> Response[list[OrganizationMemberResponse]]: + def get(self, request: Request, project: Project) -> Response[list[OrganizationMemberResponse]]: """ Returns a list of active organization members that belong to any team assigned to the project. """ diff --git a/src/sentry/api/endpoints/project_reprocessing.py b/src/sentry/api/endpoints/project_reprocessing.py index 7f813ee23429..76e144fa1c15 100644 --- a/src/sentry/api/endpoints/project_reprocessing.py +++ b/src/sentry/api/endpoints/project_reprocessing.py @@ -5,6 +5,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint, ProjectReleasePermission +from sentry.models.project import Project @cell_silo_endpoint @@ -15,7 +16,7 @@ class ProjectReprocessingEndpoint(ProjectEndpoint): } permission_classes = (ProjectReleasePermission,) - def post(self, request: Request, project) -> Response: + def post(self, request: Request, project: Project) -> Response: """ Triggers the reprocessing process as a task """ diff --git a/src/sentry/api/endpoints/project_tagkey_values.py b/src/sentry/api/endpoints/project_tagkey_values.py index 34b9e82b6bc7..afb09dea7c64 100644 --- a/src/sentry/api/endpoints/project_tagkey_values.py +++ b/src/sentry/api/endpoints/project_tagkey_values.py @@ -15,6 +15,7 @@ from sentry.apidocs.parameters import CursorQueryParam, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.environment import Environment +from sentry.models.project import Project from sentry.ratelimits.config import RateLimitConfig from sentry.tagstore.types import TagValueSerializerResponse from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -70,7 +71,9 @@ class ProjectTagKeyValuesEndpoint(ProjectEndpoint): 404: RESPONSE_NOT_FOUND, }, ) - def get(self, request: Request, project, key) -> Response[list[TagValueSerializerResponse]]: + def get( + self, request: Request, project: Project, key: str + ) -> Response[list[TagValueSerializerResponse]]: """ Return a list of values associated with this tag key. The `query` parameter can be used to perform a "contains" match on values. Paginated, returning at diff --git a/src/sentry/api/endpoints/project_tags.py b/src/sentry/api/endpoints/project_tags.py index 5446de01a290..36e605c05da1 100644 --- a/src/sentry/api/endpoints/project_tags.py +++ b/src/sentry/api/endpoints/project_tags.py @@ -17,6 +17,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import PROTECTED_TAG_KEYS from sentry.models.environment import Environment +from sentry.models.project import Project class ProjectTagKeyResponse(TypedDict): @@ -64,7 +65,7 @@ class ProjectTagsEndpoint(ProjectEndpoint): 404: RESPONSE_NOT_FOUND, }, ) - def get(self, request: Request, project) -> Response[list[ProjectTagKeyResponse]]: + def get(self, request: Request, project: Project) -> Response[list[ProjectTagKeyResponse]]: """ Return a list of the tag keys that have been seen within a project. """ @@ -73,7 +74,7 @@ def get(self, request: Request, project) -> Response[list[ProjectTagKeyResponse] except Environment.DoesNotExist: tag_keys = [] else: - kwargs: dict = {} + kwargs: dict[str, datetime.datetime] = {} # Flags are stored on the same table as tags but on a different column. Ideally both # could be queried in a single request. But at present we're not sure if we want to diff --git a/src/sentry/api/endpoints/project_transaction_names.py b/src/sentry/api/endpoints/project_transaction_names.py index 6b64807b0e3e..154b680962a9 100644 --- a/src/sentry/api/endpoints/project_transaction_names.py +++ b/src/sentry/api/endpoints/project_transaction_names.py @@ -13,6 +13,7 @@ from sentry.ingest.transaction_clusterer import rules as rule_store from sentry.ingest.transaction_clusterer.datasource import redis, snuba from sentry.ingest.transaction_clusterer.tree import TreeClusterer +from sentry.models.project import Project @cell_silo_endpoint @@ -22,7 +23,7 @@ class ProjectTransactionNamesCluster(ProjectEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project: Project) -> Response: """Run the transaction name clusterer and return its output. This endpoint is intended for internal evaluation of the clustering diff --git a/src/sentry/api/endpoints/relay/details.py b/src/sentry/api/endpoints/relay/details.py index 410ad49ea989..a4426d0ce10e 100644 --- a/src/sentry/api/endpoints/relay/details.py +++ b/src/sentry/api/endpoints/relay/details.py @@ -17,7 +17,7 @@ class RelayDetailsEndpoint(Endpoint): permission_classes = (SuperuserPermission,) owner = ApiOwner.OWNERS_INGEST - def delete(self, request: Request, relay_id) -> Response: + def delete(self, request: Request, relay_id: str) -> Response: """ Delete one Relay ```````````````` diff --git a/src/sentry/api/endpoints/setup_wizard.py b/src/sentry/api/endpoints/setup_wizard.py index 0e4e625fee57..f1c95e3bda91 100644 --- a/src/sentry/api/endpoints/setup_wizard.py +++ b/src/sentry/api/endpoints/setup_wizard.py @@ -28,7 +28,7 @@ class SetupWizard(Endpoint): } permission_classes = () - def delete(self, request: Request, wizard_hash=None) -> Response | None: + def delete(self, request: Request, wizard_hash: str | None = None) -> Response | None: """ This removes the cache content for a specific hash """ @@ -38,7 +38,7 @@ def delete(self, request: Request, wizard_hash=None) -> Response | None: return Response(status=200) return None - def get(self, request: Request, wizard_hash=None) -> Response: + def get(self, request: Request, wizard_hash: str | None = None) -> Response: """ This tries to retrieve and return the cache content if possible otherwise creates new cache diff --git a/src/sentry/api/endpoints/source_map_debug.py b/src/sentry/api/endpoints/source_map_debug.py index ca012244cca0..663963b3842f 100644 --- a/src/sentry/api/endpoints/source_map_debug.py +++ b/src/sentry/api/endpoints/source_map_debug.py @@ -1,6 +1,6 @@ import logging from collections.abc import Iterable, Mapping, Sequence -from typing import Any, Literal, NamedTuple, TypedDict +from typing import Any, Literal, NamedTuple, TypedDict, cast import sentry_sdk from django.db.models import Exists, OuterRef @@ -431,18 +431,20 @@ def _find_source_file_in_basic_uploaded_files(self) -> None: ): self.source_file_lookup_result = "found" self.found_source_file_name = possible_release_file.name - sourcemap_header = None + sourcemap_bytes: bytes | None = None if possible_release_file.file.headers: headers = ArtifactBundleArchive.normalize_headers( possible_release_file.file.headers ) - sourcemap_header = headers.get("sourcemap", headers.get("x-sourcemap")) - sourcemap_header = ( + sourcemap_header = cast( + "str | None", headers.get("sourcemap", headers.get("x-sourcemap")) + ) + sourcemap_bytes = ( force_bytes(sourcemap_header) if sourcemap_header is not None else None ) try: source_map_reference = find_sourcemap( - sourcemap_header, possible_release_file.file.getfile().read() + sourcemap_bytes, possible_release_file.file.getfile().read() ) self.source_map_reference = ( force_str(source_map_reference) @@ -482,14 +484,16 @@ def _find_source_file_in_artifact_indexes(self) -> None: self.found_source_file_name ) headers = ArtifactBundleArchive.normalize_headers(headers) - sourcemap_header = headers.get("sourcemap", headers.get("x-sourcemap")) - sourcemap_header = ( + sourcemap_header = cast( + "str | None", headers.get("sourcemap", headers.get("x-sourcemap")) + ) + sourcemap_bytes = ( force_bytes(sourcemap_header) if sourcemap_header is not None else None ) source_map_reference = find_sourcemap( - sourcemap_header, source_file.read() + sourcemap_bytes, source_file.read() ) self.source_map_reference = ( force_str(source_map_reference) @@ -529,15 +533,17 @@ def _find_source_file_in_artifact_bundles(self) -> None: headers = ArtifactBundleArchive.normalize_headers(headers) self.source_file_lookup_result = "found" self.found_source_file_name = potential_source_file_name - sourcemap_header = headers.get("sourcemap", headers.get("x-sourcemap")) - sourcemap_header = ( + sourcemap_header = cast( + "str | None", headers.get("sourcemap", headers.get("x-sourcemap")) + ) + sourcemap_bytes = ( force_bytes(sourcemap_header) if sourcemap_header is not None else None ) try: source_map_reference = find_sourcemap( - sourcemap_header, matching_file.read() + sourcemap_bytes, matching_file.read() ) self.source_map_reference = ( force_str(source_map_reference) diff --git a/src/sentry/api/endpoints/user_organizationintegrations.py b/src/sentry/api/endpoints/user_organizationintegrations.py index 56da5bac5e00..ef5e07531fab 100644 --- a/src/sentry/api/endpoints/user_organizationintegrations.py +++ b/src/sentry/api/endpoints/user_organizationintegrations.py @@ -10,6 +10,7 @@ from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.organizations.services.organization import organization_service from sentry.users.api.bases.user import UserEndpoint +from sentry.users.models.user import User from sentry.users.services.user.service import user_service @@ -20,7 +21,7 @@ class UserOrganizationIntegrationsEndpoint(UserEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, user) -> Response: + def get(self, request: Request, user: User) -> Response: """ Retrieve all of a users' organization integrations -------------------------------------------------- diff --git a/src/sentry/api/endpoints/user_subscriptions.py b/src/sentry/api/endpoints/user_subscriptions.py index 2be221c670bf..0cd912ea6af2 100644 --- a/src/sentry/api/endpoints/user_subscriptions.py +++ b/src/sentry/api/endpoints/user_subscriptions.py @@ -33,7 +33,7 @@ class UserSubscriptionsEndpoint(UserEndpoint): "POST": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, user) -> Response: + def get(self, request: Request, user: User) -> Response: """ Retrieve Account Subscriptions ````````````````````````````````````` @@ -64,7 +64,7 @@ def get(self, request: Request, user) -> Response: ] ) - def put(self, request: Request, user) -> Response: + def put(self, request: Request, user: User) -> Response: """ Update Account Subscriptions ```````````````````````````` @@ -95,7 +95,7 @@ def put(self, request: Request, user) -> Response: newsletter.backend.create_or_update_subscription(user, **kwargs) return self.respond(status=204) - def post(self, request: Request, user) -> Response: + def post(self, request: Request, user: User) -> Response: """ Configure Newsletter Subscription ````````````````````````````````` diff --git a/src/sentry/api/handlers.py b/src/sentry/api/handlers.py index 44642ad26d7d..175a6eb70710 100644 --- a/src/sentry/api/handlers.py +++ b/src/sentry/api/handlers.py @@ -1,5 +1,6 @@ import sentry_sdk from rest_framework.exceptions import Throttled +from rest_framework.response import Response from rest_framework.views import exception_handler from sentry.search.events.constants import RATE_LIMIT_ERROR_MESSAGE @@ -8,7 +9,7 @@ from sentry.utils.snuba_rpc import SnubaRPCRateLimitExceeded -def custom_exception_handler(exc, context): +def custom_exception_handler(exc: Exception, context: dict[str, object]) -> Response | None: if isinstance(exc, RateLimitExceeded): context_request = context.get("request") request = getattr(context_request, "_request", None) diff --git a/src/sentry/api/paginator.py b/src/sentry/api/paginator.py index c97e9c16a2ac..da5d3f38befc 100644 --- a/src/sentry/api/paginator.py +++ b/src/sentry/api/paginator.py @@ -3,14 +3,14 @@ import functools import logging import math -from collections.abc import Callable, Iterable, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence from datetime import datetime, timezone -from typing import Any, Protocol +from typing import Any, Protocol, cast from urllib.parse import quote from django.core.exceptions import EmptyResultSet, ObjectDoesNotExist from django.db import connections -from django.db.models import QuerySet +from django.db.models import Model, QuerySet from django.db.models.functions import Lower from sentry_protos.snuba.v1.request_common_pb2 import PageToken @@ -62,10 +62,32 @@ def __init__(self, func_name: str) -> None: super().__init__(self.error_message.format(func_name=func_name)) +class _PaginatorQuery(Protocol): + order_by: tuple[str, ...] + extra: Mapping[str, tuple[str, list[object]]] + order_b: tuple[str, ...] + + +class _PaginatorQuerySet(Protocol): + query: _PaginatorQuery + model: type[Model] + + def order_by(self, *field_names: str) -> QuerySet[Model]: ... + + def extra(self, *, where: Sequence[str], params: Sequence[object]) -> QuerySet[Model]: ... + + class BasePaginator: def __init__( - self, queryset, order_by=None, max_limit=MAX_LIMIT, on_results=None, post_query_filter=None - ): + self, + queryset: QuerySet[Model], + order_by: str | None = None, + max_limit: int = MAX_LIMIT, + on_results: Callable[[Sequence[Model]], Sequence[Model]] | None = None, + post_query_filter: Callable[[Sequence[Model]], Sequence[Model]] | None = None, + ) -> None: + self.key: str | None + self.desc: bool if order_by: if order_by.startswith("-"): self.key, self.desc = order_by[1:], True @@ -83,7 +105,8 @@ def _is_asc(self, is_prev: bool) -> bool: return (self.desc and is_prev) or not (self.desc or is_prev) def build_queryset(self, value, is_prev): - queryset = self.queryset + source: _PaginatorQuerySet = cast(_PaginatorQuerySet, self.queryset) + queryset: QuerySet[Model] = self.queryset # "asc" controls whether or not we need to change the ORDER BY to # ascending. If we're sorting by DESC but we're using a previous @@ -97,18 +120,18 @@ def build_queryset(self, value, is_prev): # previous page so we know exactly where we ended last page. The # results will get reversed back to the requested order below. if self.key: - if self.key in queryset.query.order_by: + if self.key in source.query.order_by: if not asc: - index = queryset.query.order_by.index(self.key) - new_order_by = list(queryset.query.order_by) - new_order_by[index] = f"-{queryset.query.order_by[index]}" - queryset.query.order_by = tuple(new_order_by) - elif ("-%s" % self.key) in queryset.query.order_by: + new_order_by = list(source.query.order_by) + index = new_order_by.index(self.key) + new_order_by[index] = f"-{new_order_by[index]}" + source.query.order_by = tuple(new_order_by) + elif ("-%s" % self.key) in source.query.order_by: if asc: - index = queryset.query.order_by.index(f"-{self.key}") - new_order_by = list(queryset.query.order_by) - new_order_by[index] = queryset.query.order_by[index][1:] - queryset.query.order_b = tuple(new_order_by) + new_order_by = list(source.query.order_by) + index = new_order_by.index(f"-{self.key}") + new_order_by[index] = new_order_by[index][1:] + source.query.order_b = tuple(new_order_by) else: if asc: queryset = queryset.order_by(self.key) @@ -117,8 +140,9 @@ def build_queryset(self, value, is_prev): if value: assert self.key - if self.key in queryset.query.extra: - col_query, col_params = queryset.query.extra[self.key] + source = cast(_PaginatorQuerySet, queryset) + if self.key in source.query.extra: + col_query, col_params = source.query.extra[self.key] col_params = col_params[:] else: col_query, col_params = quote_name(self.key), [] @@ -126,7 +150,7 @@ def build_queryset(self, value, is_prev): col = col_query if "." in col_query else f"{queryset.model._meta.db_table}.{col_query}" operator = ">=" if asc else "<=" - queryset = queryset.extra( + queryset = source.extra( where=[f"{col} {operator} %s"], params=col_params, ) @@ -139,7 +163,14 @@ def get_item_key(self, item: Any, for_prev: bool = False) -> CursorValue: def value_from_cursor(self, cursor: Cursor) -> CursorValue: raise NotImplementedError - def get_result(self, limit=100, cursor=None, count_hits=False, known_hits=None, max_hits=None): + def get_result( + self, + limit: int = 100, + cursor: Cursor | None = None, + count_hits: bool = False, + known_hits: int | None = None, + max_hits: int | None = None, + ) -> CursorResult[Model]: # cursors are: # (identifier(integer), row offset, is_prev) if cursor is None: @@ -155,7 +186,7 @@ def get_result(self, limit=100, cursor=None, count_hits=False, known_hits=None, else: cursor_value = 0 - queryset = self.build_queryset(cursor_value, cursor.is_prev) + queryset: QuerySet[Model] = self.build_queryset(cursor_value, cursor.is_prev) # TODO(dcramer): this does not yet work correctly for ``is_prev`` when # the key is not unique @@ -186,7 +217,7 @@ def get_result(self, limit=100, cursor=None, count_hits=False, known_hits=None, extra += 1 stop = offset + limit + extra - results = list(queryset[offset:stop]) + results: list[Model] = list(queryset[offset:stop]) if cursor.is_prev and cursor.value: # If the first result is equal to the cursor_value then it's safe to filter @@ -200,7 +231,7 @@ def get_result(self, limit=100, cursor=None, count_hits=False, known_hits=None, if cursor.is_prev: results.reverse() - cursor = build_cursor( + result: CursorResult[Model] = build_cursor( results=results, limit=limit, hits=hits, @@ -215,9 +246,9 @@ def get_result(self, limit=100, cursor=None, count_hits=False, known_hits=None, # This will reduce the number of rows returned rather than fill a full page, # and could result in an empty page being returned if self.post_query_filter: - cursor.results = self.post_query_filter(cursor.results) + result.results = self.post_query_filter(result.results) - return cursor + return result def count_hits(self, max_hits: int) -> int: return count_hits(self.queryset, max_hits) @@ -225,7 +256,7 @@ def count_hits(self, max_hits: int) -> int: class Paginator(BasePaginator): def get_item_key(self, item: Any, for_prev: bool = False) -> int: - value = getattr(item, self.key) + value = getattr(item, cast(str, self.key)) return int(math.floor(value) if self._is_asc(for_prev) else math.ceil(value)) def value_from_cursor(self, cursor: Cursor) -> CursorValue: @@ -236,7 +267,7 @@ class DateTimePaginator(BasePaginator): multiplier = 1000 def get_item_key(self, item: Any, for_prev: bool = False) -> int: - value = getattr(item, self.key) + value = getattr(item, cast(str, self.key)) value = float(value.strftime("%s.%f")) * self.multiplier return int(math.floor(value) if self._is_asc(for_prev) else math.ceil(value)) @@ -459,7 +490,14 @@ def __init__( self.max_limit = max_limit self.on_results = on_results - def get_result(self, limit, cursor=None, count_hits=False, known_hits=None, max_hits=None): + def get_result( + self, + limit: int, + cursor: Cursor | None = None, + count_hits: bool = False, + known_hits: int | None = None, + max_hits: int | None = None, + ) -> CursorResult[T]: limit = min(limit, self.max_limit) if cursor is None: @@ -548,7 +586,7 @@ class GenericOffsetPaginator: def __init__(self, data_fn): self.data_fn = data_fn - def get_result(self, limit, cursor=None): + def get_result(self, limit: int, cursor: Cursor | None = None) -> CursorResult[object]: assert limit > 0 offset = cursor.offset if cursor is not None else 0 if offset < 0: @@ -865,7 +903,7 @@ class EAPPageTokenPaginator: def __init__(self, data_fn): self.data_fn = data_fn - def get_result(self, limit, cursor=None): + def get_result(self, limit: int, cursor: Cursor | None = None) -> CursorResult[object]: assert limit > 0 page_token = self.page_token_from_cursor(cursor) diff --git a/src/sentry/api/serializers/models/activity.py b/src/sentry/api/serializers/models/activity.py index 04c5c1006be2..83f7da973672 100644 --- a/src/sentry/api/serializers/models/activity.py +++ b/src/sentry/api/serializers/models/activity.py @@ -1,6 +1,6 @@ -from collections.abc import Sequence +from collections.abc import Callable, Sequence from datetime import datetime -from typing import Any, TypeAlias, TypedDict +from typing import TypeAlias, TypedDict from django.contrib.auth.models import AnonymousUser @@ -10,6 +10,7 @@ from sentry.issues.models.groupactionlogentry import GroupActionLogEntry from sentry.models.activity import Activity from sentry.models.commit import Commit +from sentry.models.environment import Environment from sentry.models.group import Group from sentry.models.pullrequest import PullRequest from sentry.sentry_apps.api.serializers.sentry_app_avatar import ( @@ -51,13 +52,13 @@ class ActivitySerializerResponse(TypedDict): commentId: str | None # The serialized acting user (a user serializer response), or null for # system/integration activity. Left loose: the full user shape is out of scope. - user: dict[str, Any] | None + user: dict[str, object] | None sentry_app: _ActivitySentryAppEmbed | None type: str # Polymorphic by activity type (note text, commit, pull request, unmerge # fingerprints + source/destination, ...). Loose by design — describing every # variant is out of scope. - data: dict[str, Any] + data: dict[str, object] dateCreated: datetime @@ -123,7 +124,7 @@ def serialize_group_notes( def _resolve_mentioned_users( - activities: list[Activity], + activities: Sequence[Activity], ) -> dict[ActivityId, list[ResolvedMention]]: """The Sentry users each activity @mentions, keyed by activity id, resolved in one batch. @@ -160,11 +161,20 @@ def _resolve_mentioned_users( @register(Activity) class ActivitySerializer(Serializer): - def __init__(self, environment_func=None, resolve_mentions: bool = False): + def __init__( + self, + environment_func: Callable[[], Environment | None] | None = None, + resolve_mentions: bool = False, + ) -> None: self.environment_func = environment_func self.resolve_mentions = resolve_mentions - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[Activity], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[Activity, dict[str, object]]: from sentry.api.serializers.models.group import GroupSerializer # TODO(dcramer); assert on relations @@ -271,7 +281,13 @@ def get_attrs(self, item_list, user, **kwargs): for item in item_list } - def serialize(self, obj: Activity, attrs, user, **kwargs): + def serialize( + self, + obj: Activity, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ActivitySerializerResponse: if ( obj.type == ActivityType.SET_RESOLVED_IN_RELEASE.value and obj.data diff --git a/src/sentry/api/serializers/models/actor.py b/src/sentry/api/serializers/models/actor.py index d873d8fce336..efe5efb9aae6 100644 --- a/src/sentry/api/serializers/models/actor.py +++ b/src/sentry/api/serializers/models/actor.py @@ -1,6 +1,13 @@ -from typing import Literal, NotRequired, TypedDict +from __future__ import annotations + +from collections.abc import Mapping +from typing import Literal, NotRequired, Protocol, TypedDict, cast + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class ActorSerializerResponse(TypedDict): @@ -10,15 +17,41 @@ class ActorSerializerResponse(TypedDict): email: NotRequired[str] -class ActorSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs) -> ActorSerializerResponse: +class _Actor(Protocol): + id: int + + def class_name(self) -> str: ... + + +class _UserActor(_Actor, Protocol): + email: str + + def get_display_name(self) -> str: ... + + +class _TeamActor(_Actor, Protocol): + slug: str + + +class ActorSerializer(Serializer[ActorSerializerResponse]): + def serialize( + self, + obj: _Actor, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ActorSerializerResponse: if obj.class_name() == "User": - name = obj.get_display_name() - return ActorSerializerResponse(type="user", id=str(obj.id), name=name, email=obj.email) + actor = cast(_UserActor, obj) + name = actor.get_display_name() + return ActorSerializerResponse( + type="user", id=str(actor.id), name=name, email=actor.email + ) elif obj.class_name() == "Team": - name = obj.slug - return ActorSerializerResponse(type="team", id=str(obj.id), name=name) + team_actor = cast(_TeamActor, obj) + name = team_actor.slug + return ActorSerializerResponse(type="team", id=str(team_actor.id), name=name) else: raise AssertionError(f"Invalid type to assign to: {type(obj)}") diff --git a/src/sentry/api/serializers/models/agentic_onboarding.py b/src/sentry/api/serializers/models/agentic_onboarding.py index 7bd6da651006..eaa65ce40985 100644 --- a/src/sentry/api/serializers/models/agentic_onboarding.py +++ b/src/sentry/api/serializers/models/agentic_onboarding.py @@ -1,6 +1,8 @@ from collections.abc import Mapping from typing import Any, NotRequired, TypedDict +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer from sentry.api.serializers.rest_framework import convert_dict_key_case, snake_to_camel_case from sentry.onboarding.agentic_progress.model import ( @@ -10,6 +12,8 @@ StageExtra, StageStatus, ) +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.utils import json @@ -47,7 +51,7 @@ def serialize( self, obj: OnboardingRun, attrs: Mapping[str, Any], - user: Any, + user: User | RpcUser | AnonymousUser, **kwargs: Any, ) -> AgenticOnboardingRunResponse: data = AgenticOnboardingRunResponse( diff --git a/src/sentry/api/serializers/models/apiapplication.py b/src/sentry/api/serializers/models/apiapplication.py index ec544f3c46c9..036dd8036246 100644 --- a/src/sentry/api/serializers/models/apiapplication.py +++ b/src/sentry/api/serializers/models/apiapplication.py @@ -1,14 +1,41 @@ -from datetime import timedelta +from collections.abc import Mapping +from datetime import datetime, timedelta +from typing import TypedDict +from django.contrib.auth.models import AnonymousUser from django.utils import timezone from sentry.api.serializers import Serializer, register from sentry.models.apiapplication import ApiApplication +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser + + +class ApiApplicationSerializerResponse(TypedDict): + id: str + clientID: str + clientSecret: str | None + dateCreated: datetime + isPublic: bool + name: str + homepageUrl: str | None + privacyUrl: str | None + termsUrl: str | None + allowedOrigins: list[str] + redirectUris: list[str] + scopes: list[str] + requiresOrgLevelAccess: bool @register(ApiApplication) -class ApiApplicationSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): +class ApiApplicationSerializer(Serializer[ApiApplicationSerializerResponse]): + def serialize( + self, + obj: ApiApplication, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser | None, + **kwargs: object, + ) -> ApiApplicationSerializerResponse: is_owner = user and not user.is_anonymous and user.id == obj.owner_id has_user_context = user is not None and not getattr(user, "is_anonymous", True) # NOTE: When no user is passed, the secret remains visible to diff --git a/src/sentry/api/serializers/models/apiauthorization.py b/src/sentry/api/serializers/models/apiauthorization.py index 4dc4517e9e9e..dca3ad1230c3 100644 --- a/src/sentry/api/serializers/models/apiauthorization.py +++ b/src/sentry/api/serializers/models/apiauthorization.py @@ -1,28 +1,56 @@ +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import TypedDict, cast + +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register, serialize +from sentry.api.serializers.models.apiapplication import ApiApplicationSerializerResponse from sentry.models.apiauthorization import ApiAuthorization from sentry.organizations.services.organization import organization_service +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser + + +class ApiAuthorizationSerializerResponse(TypedDict): + id: str + scopes: list[str] + application: ApiApplicationSerializerResponse | None + dateCreated: datetime + organization: object @register(ApiAuthorization) -class ApiAuthorizationSerializer(Serializer): - def get_attrs(self, item_list, user, **kwargs): +class ApiAuthorizationSerializer(Serializer[ApiAuthorizationSerializerResponse]): + def get_attrs( + self, + item_list: Sequence[ApiAuthorization], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[ApiAuthorization, dict[str, object]]: apps = { d["id"]: d for d in serialize({i.application for i in item_list if i.application_id}, user) } - attrs = {} + attrs: dict[ApiAuthorization, dict[str, object]] = {} for item in item_list: attrs[item] = { "application": (apps.get(item.application.client_id) if item.application else None) } return attrs - def serialize(self, obj, attrs, user, **kwargs): - return { + def serialize( + self, + obj: ApiAuthorization, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ApiAuthorizationSerializerResponse: + data: ApiAuthorizationSerializerResponse = { "id": str(obj.id), "scopes": obj.get_scopes(), - "application": attrs["application"], + "application": cast(ApiApplicationSerializerResponse | None, attrs["application"]), "dateCreated": obj.date_added, "organization": ( organization_service.serialize_organization(id=obj.organization_id) @@ -30,3 +58,4 @@ def serialize(self, obj, attrs, user, **kwargs): else None ), } + return data diff --git a/src/sentry/api/serializers/models/apikey.py b/src/sentry/api/serializers/models/apikey.py index 3ce8b16d98d5..81738a18f261 100644 --- a/src/sentry/api/serializers/models/apikey.py +++ b/src/sentry/api/serializers/models/apikey.py @@ -1,10 +1,32 @@ +from collections.abc import Mapping +from typing import TypedDict + +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.models.apikey import ApiKey +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser + + +class ApiKeySerializerResponse(TypedDict): + id: str + label: str + key: str + scope_list: list[str] + status: int + allowed_origins: str @register(ApiKey) -class ApiKeySerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): +class ApiKeySerializer(Serializer[ApiKeySerializerResponse]): + def serialize( + self, + obj: ApiKey, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ApiKeySerializerResponse: return { "id": str(obj.id), "label": obj.label, diff --git a/src/sentry/api/serializers/models/apitoken.py b/src/sentry/api/serializers/models/apitoken.py index 13d206a8feb8..69819e843adf 100644 --- a/src/sentry/api/serializers/models/apitoken.py +++ b/src/sentry/api/serializers/models/apitoken.py @@ -1,32 +1,66 @@ +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import NotRequired, TypedDict, cast + +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register, serialize +from sentry.api.serializers.models.apiapplication import ApiApplicationSerializerResponse from sentry.models.apitoken import ApiToken from sentry.types.token import AuthTokenType +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser + + +class ApiTokenSerializerResponse(TypedDict): + id: str + scopes: list[str] + name: str | None + application: ApiApplicationSerializerResponse | None + expiresAt: datetime | None + dateCreated: datetime + state: str | None + token: NotRequired[str] + refreshToken: NotRequired[str] + tokenLastCharacters: str | None @register(ApiToken) -class ApiTokenSerializer(Serializer): - def get_attrs(self, item_list, user, **kwargs): +class ApiTokenSerializer(Serializer[ApiTokenSerializerResponse]): + def get_attrs( + self, + item_list: Sequence[ApiToken], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[ApiToken, dict[str, object]]: apps = { d["id"]: d for d in serialize({i.application for i in item_list if i.application_id}, user) } - attrs = {} + attrs: dict[ApiToken, dict[str, object]] = {} for item in item_list: attrs[item] = { "application": (apps.get(item.application.client_id) if item.application else None) } return attrs - def serialize(self, obj, attrs, user, **kwargs): - data = { + def serialize( + self, + obj: ApiToken, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ApiTokenSerializerResponse: + data: ApiTokenSerializerResponse = { "id": str(obj.id), "scopes": obj.get_scopes(), "name": obj.name, - "application": attrs["application"], + "application": cast(ApiApplicationSerializerResponse | None, attrs["application"]), "expiresAt": obj.expires_at, "dateCreated": obj.date_added, - "state": attrs.get("state"), + "state": cast(str | None, attrs.get("state")), + "tokenLastCharacters": obj.token_last_characters, } if not attrs["application"]: include_token = kwargs.get("include_token", True) @@ -41,5 +75,4 @@ def serialize(self, obj, attrs, user, **kwargs): token was created through improper channels, and might actually be invalid. If that's the case, it can be considered a security risk, and better for this to throw an exception than to leak a dangerous token. """ - data["tokenLastCharacters"] = obj.token_last_characters return data diff --git a/src/sentry/api/serializers/models/artifactbundle.py b/src/sentry/api/serializers/models/artifactbundle.py index ad4dcedfe59c..f35465e70a2f 100644 --- a/src/sentry/api/serializers/models/artifactbundle.py +++ b/src/sentry/api/serializers/models/artifactbundle.py @@ -1,15 +1,49 @@ import base64 +import uuid +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import TypedDict, cast + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer from sentry.models.artifactbundle import ReleaseArtifactBundle, SourceFileType +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser INVALID_SOURCE_FILE_TYPE = 0 +type ArtifactBundleRow = tuple[int, uuid.UUID, int, datetime | None, datetime] + + +class ArtifactBundleAssociationResponse(TypedDict): + release: str | None + dist: str | None + + +class ArtifactBundlesSerializerResponse(TypedDict): + bundleId: str + associations: list[ArtifactBundleAssociationResponse] + fileCount: int + dateModified: str | None + date: str | None -class ArtifactBundlesSerializer(Serializer): + +class ArtifactBundleFilesSerializerResponse(TypedDict): + id: str + fileType: int + filePath: str | None + fileSize: int | None + debugId: str | None + sourcemap: str | None + + +class ArtifactBundlesSerializer(Serializer[ArtifactBundlesSerializerResponse]): @staticmethod - def _compute_associations(item, grouped_bundles): - associations = [] + def _compute_associations( + item: ArtifactBundleRow, grouped_bundles: dict[int, list[tuple[str, str]]] + ) -> list[ArtifactBundleAssociationResponse]: + associations: list[ArtifactBundleAssociationResponse] = [] grouped_bundle = grouped_bundles.get(item[0], []) # We preserve the order of the list inside the grouped bundle. @@ -19,10 +53,15 @@ def _compute_associations(item, grouped_bundles): return associations @staticmethod - def _format_date(date): + def _format_date(date: datetime | None) -> str | None: return None if date is None else date.isoformat()[:19] + "Z" - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[ArtifactBundleRow], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[ArtifactBundleRow, dict[str, object]]: # We sort by id, since it's the best (already existing) field to define total order of # release associations that is somehow consistent with upload sequence. release_artifact_bundles = ReleaseArtifactBundle.objects.filter( @@ -45,13 +84,19 @@ def get_attrs(self, item_list, user, **kwargs): for item in item_list } - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: ArtifactBundleRow, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ArtifactBundlesSerializerResponse: return { "bundleId": str(attrs["bundle_id"]), - "associations": attrs["associations"], - "fileCount": attrs["file_count"], - "dateModified": self._format_date(attrs["date_last_modified"]), - "date": self._format_date(attrs["date_uploaded"]), + "associations": cast("list[ArtifactBundleAssociationResponse]", attrs["associations"]), + "fileCount": cast(int, attrs["file_count"]), + "dateModified": self._format_date(cast("datetime | None", attrs["date_last_modified"])), + "date": self._format_date(cast("datetime | None", attrs["date_uploaded"])), } diff --git a/src/sentry/api/serializers/models/auditlogentry.py b/src/sentry/api/serializers/models/auditlogentry.py index faaf9c5145a4..b6cd29220835 100644 --- a/src/sentry/api/serializers/models/auditlogentry.py +++ b/src/sentry/api/serializers/models/auditlogentry.py @@ -1,6 +1,10 @@ import re +from collections.abc import Mapping, Sequence from copy import copy +from datetime import datetime +from typing import TypedDict, cast +from django.contrib.auth.models import AnonymousUser from django.db.models import prefetch_related_objects from sentry_sdk import capture_exception @@ -8,6 +12,8 @@ from sentry.api.serializers import Serializer, register, serialize from sentry.audit_log.metadata import AGENT_DELEGATION_DATA_KEY, SEER_AGENT_DELEGATION from sentry.models.auditlogentry import AuditLogEntry +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser def fix(data): @@ -36,9 +42,27 @@ def override_actor_id(user): return scim_match +class AuditLogEntrySerializerResponse(TypedDict): + id: str + actor: dict[str, object] + event: str + ipAddress: str | None + note: str + targetObject: int | None + targetUser: dict[str, object] | int | None + data: dict[str, object] + agentDelegation: object | None + dateCreated: datetime + + @register(AuditLogEntry) -class AuditLogEntrySerializer(Serializer): - def get_attrs(self, item_list, user, **kwargs): +class AuditLogEntrySerializer(Serializer[AuditLogEntrySerializerResponse]): + def get_attrs( + self, + item_list: Sequence[AuditLogEntry], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[AuditLogEntry, dict[str, object]]: # TODO(dcramer); assert on relations prefetch_related_objects(item_list, "actor") prefetch_related_objects(item_list, "target_user") @@ -64,7 +88,13 @@ def get_attrs(self, item_list, user, **kwargs): for item in item_list } - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: AuditLogEntry, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> AuditLogEntrySerializerResponse: audit_log_event = audit_log.get(obj.event) agent_delegation = obj.data.get(AGENT_DELEGATION_DATA_KEY) render_obj = obj @@ -85,12 +115,12 @@ def serialize(self, obj, attrs, user, **kwargs): return { "id": str(obj.id), - "actor": attrs["actor"], + "actor": cast(dict[str, object], attrs["actor"]), "event": audit_log_event.api_name, "ipAddress": obj.ip_address, "note": note, "targetObject": obj.target_object, - "targetUser": attrs["targetUser"], + "targetUser": cast(dict[str, object] | int | None, attrs["targetUser"]), "data": fix(obj.data), "agentDelegation": agent_delegation, "dateCreated": obj.datetime, diff --git a/src/sentry/api/serializers/models/broadcast.py b/src/sentry/api/serializers/models/broadcast.py index 427491a23bbb..e43a5b6dcc28 100644 --- a/src/sentry/api/serializers/models/broadcast.py +++ b/src/sentry/api/serializers/models/broadcast.py @@ -1,13 +1,45 @@ +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import TypedDict, cast + +from django.contrib.auth.models import AnonymousUser from django.db.models import Count from sentry.api.serializers import Serializer, register from sentry.auth.principal import has_user from sentry.models.broadcast import Broadcast, BroadcastSeen +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser + + +class BroadcastSerializerResponse(TypedDict): + id: str + message: str + title: str + link: str | None + mediaUrl: str | None + isActive: bool + dateCreated: datetime + dateExpires: datetime | None + hasSeen: bool + category: str | None + + +class AdminBroadcastSerializerResponse(BroadcastSerializerResponse): + userCount: int + createdBy: str | None + upstreamId: str | None + syncLocked: bool @register(Broadcast) -class BroadcastSerializer(Serializer): - def get_attrs(self, item_list, user, **kwargs): +class BroadcastSerializer(Serializer[BroadcastSerializerResponse]): + def get_attrs( + self, + item_list: Sequence[Broadcast], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[Broadcast, dict[str, object]]: if not has_user(user): seen = set() else: @@ -19,7 +51,13 @@ def get_attrs(self, item_list, user, **kwargs): return {item: {"seen": item.id in seen} for item in item_list} - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: Broadcast, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> BroadcastSerializerResponse: return { "id": str(obj.id), "message": obj.message, @@ -29,13 +67,18 @@ def serialize(self, obj, attrs, user, **kwargs): "isActive": obj.is_active, "dateCreated": obj.date_added, "dateExpires": obj.date_expires, - "hasSeen": attrs["seen"], + "hasSeen": cast(bool, attrs["seen"]), "category": obj.category, } class AdminBroadcastSerializer(BroadcastSerializer): - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[Broadcast], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[Broadcast, dict[str, object]]: attrs = super().get_attrs(item_list, user) counts = dict( BroadcastSeen.objects.filter(broadcast__in=item_list) @@ -49,10 +92,18 @@ def get_attrs(self, item_list, user, **kwargs): attrs[item]["user_count"] = counts.get(item.id, 0) return attrs - def serialize(self, obj, attrs, user, **kwargs): - context = super().serialize(obj, attrs, user) - context["userCount"] = attrs["user_count"] - context["createdBy"] = obj.created_by_id.email if obj.created_by_id else None - context["upstreamId"] = obj.upstream_id - context["syncLocked"] = obj.sync_locked + def serialize( + self, + obj: Broadcast, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> AdminBroadcastSerializerResponse: + context: AdminBroadcastSerializerResponse = { + **super().serialize(obj, attrs, user), + "userCount": cast(int, attrs["user_count"]), + "createdBy": obj.created_by_id.email if obj.created_by_id else None, + "upstreamId": obj.upstream_id, + "syncLocked": obj.sync_locked, + } return context diff --git a/src/sentry/api/serializers/models/commit.py b/src/sentry/api/serializers/models/commit.py index 50aee9b6cbb8..e847cae9caa7 100644 --- a/src/sentry/api/serializers/models/commit.py +++ b/src/sentry/api/serializers/models/commit.py @@ -1,8 +1,10 @@ from collections import defaultdict -from collections.abc import Mapping +from collections.abc import Collection, Mapping, Sequence from datetime import datetime from typing import NotRequired, TypedDict +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register, serialize from sentry.api.serializers.models.pullrequest import PullRequestSerializerResponse from sentry.api.serializers.models.release import get_users_for_authors @@ -12,6 +14,8 @@ from sentry.models.commitauthor import CommitAuthor from sentry.models.pullrequest import PullRequest from sentry.models.repository import Repository +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class EmptyAuthor(TypedDict): @@ -44,7 +48,9 @@ class CommitSerializerResponseWithReleases(CommitSerializerResponse): releases: list[CommitReleaseSerializerResponse] -def get_users_for_commits(item_list, user=None) -> Mapping[str, Author]: +def get_users_for_commits( + item_list: Sequence[Commit], user: User | RpcUser | AnonymousUser | None = None +) -> Mapping[str, Author]: authors = list( CommitAuthor.objects.get_many_from_cache([i.author_id for i in item_list if i.author_id]) ) @@ -57,13 +63,25 @@ def get_users_for_commits(item_list, user=None) -> Mapping[str, Author]: @register(Commit) -class CommitSerializer(Serializer): - def __init__(self, exclude=None, include=None, type=None, *args, **kwargs): +class CommitSerializer(Serializer[CommitSerializerResponse]): + def __init__( + self, + exclude: Collection[str] | None = None, + include: Collection[str] | None = None, + type: str | None = None, + *args: object, + **kwargs: object, + ) -> None: Serializer.__init__(self, *args, **kwargs) self.exclude = frozenset(exclude if exclude else ()) self.type = type or "" - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[Commit], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[Commit, dict[str, object]]: if "author" not in self.exclude: users_by_author = get_users_for_commits(item_list, user) else: @@ -101,7 +119,13 @@ def get_attrs(self, item_list, user, **kwargs): return result - def serialize(self, obj: Commit, attrs, user, **kwargs) -> CommitSerializerResponse: + def serialize( + self, + obj: Commit, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> CommitSerializerResponse: d: CommitSerializerResponse = { "id": obj.key, "message": obj.message, @@ -118,12 +142,24 @@ def serialize(self, obj: Commit, attrs, user, **kwargs) -> CommitSerializerRespo @register(Commit) class CommitWithReleaseSerializer(CommitSerializer): - def __init__(self, exclude=None, include=None, type=None, *args, **kwargs): + def __init__( + self, + exclude: Collection[str] | None = None, + include: Collection[str] | None = None, + type: str | None = None, + *args: object, + **kwargs: object, + ) -> None: Serializer.__init__(self, *args, **kwargs) self.exclude = frozenset(exclude if exclude else ()) self.type = type or "" - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[Commit], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[Commit, dict[str, object]]: from sentry.models.releasecommit import ReleaseCommit attrs = super().get_attrs(item_list, user) @@ -137,7 +173,13 @@ def get_attrs(self, item_list, user, **kwargs): attrs[item]["releases"] = releases_by_commit[item.id] return attrs - def serialize(self, obj, attrs, user, **kwargs) -> CommitSerializerResponseWithReleases: + def serialize( + self, + obj: Commit, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> CommitSerializerResponseWithReleases: data = super().serialize(obj, attrs, user) ret: CommitSerializerResponseWithReleases = { "id": data["id"], diff --git a/src/sentry/api/serializers/models/dashboard.py b/src/sentry/api/serializers/models/dashboard.py index db04d7e88664..ad49dadf6c2f 100644 --- a/src/sentry/api/serializers/models/dashboard.py +++ b/src/sentry/api/serializers/models/dashboard.py @@ -1,11 +1,13 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Sequence from datetime import datetime -from typing import Any, NotRequired, TypedDict +from typing import NotRequired, TypedDict, cast from urllib.parse import urlencode import sentry_sdk +from django.contrib.auth.models import AnonymousUser from django.db.models import prefetch_related_objects from sentry import features @@ -30,7 +32,8 @@ from sentry.organizations.absolute_url import has_customer_domain, organization_absolute_url from sentry.search.events.fields import is_function, parse_arguments from sentry.snuba.metrics.extraction import OnDemandMetricSpecVersioning -from sentry.users.api.serializers.user import UserSerializerResponse +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service from sentry.utils import json from sentry.utils.avatar import get_gravatar_url @@ -77,19 +80,21 @@ class ThresholdType(ThresholdTypeOptional): unit: str -def _convert_thresholds_to_camel_case(thresholds: dict[str, Any] | None) -> ThresholdType | None: +def _convert_thresholds_to_camel_case( + thresholds: dict[str, object] | None, +) -> ThresholdType | None: if thresholds is None: return None result: ThresholdType = { # We currently do not convert max_values to camelCase because the frontend already expects it in snake_case. - "max_values": thresholds.get("max_values", {}), - "unit": thresholds.get("unit", ""), + "max_values": cast("dict[str, int]", thresholds.get("max_values", {})), + "unit": cast(str, thresholds.get("unit", "")), } if thresholds.get("preferred_polarity") is not None: - result["preferredPolarity"] = thresholds["preferred_polarity"] + result["preferredPolarity"] = cast(str, thresholds["preferred_polarity"]) if thresholds.get("time_window") is not None: - result["timeWindow"] = thresholds["time_window"] + result["timeWindow"] = cast(str, thresholds["time_window"]) return result @@ -106,7 +111,7 @@ class DashboardWidgetResponse(TypedDict): displayType: str thresholds: ThresholdType | None interval: str - dateCreated: str + dateCreated: datetime dashboardId: str queries: list[DashboardWidgetQueryResponse] limit: int | None @@ -380,7 +385,13 @@ def serialize(self, obj, attrs, user, **kwargs) -> DashboardWidgetResponse: @register(DashboardWidgetQueryOnDemand) class DashboardWidgetQueryOnDemandSerializer(Serializer[OnDemandResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> OnDemandResponse: + def serialize( + self, + obj: DashboardWidgetQueryOnDemand, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> OnDemandResponse: return { "enabled": obj.extraction_enabled(), "extractionState": obj.extraction_state, @@ -390,28 +401,36 @@ def serialize(self, obj, attrs, user, **kwargs) -> OnDemandResponse: @register(DashboardWidgetQuery) class DashboardWidgetQuerySerializer(Serializer[DashboardWidgetQueryResponse]): - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[DashboardWidgetQuery], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[DashboardWidgetQuery, dict[str, list[object]]]: result = {} stateful_extraction_version = ( OnDemandMetricSpecVersioning.get_default_spec_version().version ) - data_sources = serialize( - list( - DashboardWidgetQueryOnDemand.objects.filter( - dashboard_widget_query_id__in=[i.id for i in item_list], - spec_version=stateful_extraction_version, + data_sources = cast( + "list[OnDemandResponse]", + serialize( + list( + DashboardWidgetQueryOnDemand.objects.filter( + dashboard_widget_query_id__in=[i.id for i in item_list], + spec_version=stateful_extraction_version, + ) ) - ) + ), ) for widget_query in item_list: - widget_data_sources = [ + widget_data_sources: list[object] = [ d for d in data_sources if d["dashboardWidgetQueryId"] == widget_query.id ] # Convert field links to response format - linked_dashboards = [ + linked_dashboards: list[object] = [ {"field": link.field, "dashboardId": link.dashboard_id} for link in widget_query.dashboardfieldlink_set.all() ] @@ -423,7 +442,13 @@ def get_attrs(self, item_list, user, **kwargs): return result - def serialize(self, obj, attrs, user, **kwargs) -> DashboardWidgetQueryResponse: + def serialize( + self, + obj: DashboardWidgetQuery, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> DashboardWidgetQueryResponse: return { "id": str(obj.id), "name": obj.name, @@ -444,21 +469,29 @@ def serialize(self, obj, attrs, user, **kwargs) -> DashboardWidgetQueryResponse: @register(DashboardPermissions) class DashboardPermissionsSerializer(Serializer[DashboardPermissionsResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> DashboardPermissionsResponse: + def serialize( + self, + obj: DashboardPermissions, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> DashboardPermissionsResponse: return { "isEditableByEveryone": obj.is_editable_by_everyone, - "teamsWithEditAccess": list(obj.teams_with_edit_access.values_list("id", flat=True)), + "teamsWithEditAccess": cast( + list[int], list(obj.teams_with_edit_access.values_list("id", flat=True)) + ), } class DashboardListResponse(TypedDict): id: str title: str - dateCreated: str - createdBy: UserSerializerResponse + dateCreated: datetime + createdBy: dict[str, object] | None environment: list[str] filters: DashboardFilters - lastVisited: str | None + lastVisited: datetime | None widgetDisplay: list[str] widgetPreview: list[dict[str, str]] permissions: DashboardPermissionsResponse | None @@ -475,8 +508,8 @@ class _WidgetPreview(TypedDict): class _Widget(TypedDict): widget_display: list[str] widget_preview: list[_WidgetPreview] - created_by: dict[str, Any] | None - permissions: NotRequired[dict[str, Any]] + created_by: dict[str, object] | None + permissions: NotRequired[dict[str, object]] is_favorited: NotRequired[bool] projects: list[int] environment: list[str] @@ -627,7 +660,13 @@ def get_attrs(self, item_list, user, **kwargs): return result - def serialize(self, obj, attrs, user, **kwargs) -> DashboardListResponse: + def serialize( + self, + obj: Dashboard, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> DashboardListResponse: return { "id": str(obj.id), "title": obj.title, @@ -648,7 +687,7 @@ def serialize(self, obj, attrs, user, **kwargs) -> DashboardListResponse: class DashboardFilters(TypedDict, total=False): release: list[str] releaseId: list[str] - globalFilter: list[dict[str, Any]] + globalFilter: list[dict[str, object]] class DashboardDetailsResponseOptional(TypedDict, total=False): @@ -663,8 +702,8 @@ class DashboardDetailsResponseOptional(TypedDict, total=False): class DashboardDetailsResponse(DashboardDetailsResponseOptional): id: str title: str - dateCreated: str - createdBy: UserSerializerResponse | None + dateCreated: datetime + createdBy: dict[str, object] | None widgets: list[DashboardWidgetResponse] projects: list[int] filters: DashboardFilters @@ -675,23 +714,33 @@ class DashboardDetailsResponse(DashboardDetailsResponseOptional): @register(Dashboard) class DashboardDetailsModelSerializer(Serializer, DashboardFiltersMixin): - def get_attrs(self, item_list, user, **kwargs): - result = {} - - widgets = serialize( - list( - DashboardWidget.objects.filter(dashboard_id__in=[i.id for i in item_list]).order_by( - "id" - ) + def get_attrs( + self, + item_list: Sequence[Dashboard], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[Dashboard, dict[str, object]]: + result: dict[Dashboard, dict[str, object]] = {} + + widgets = cast( + "list[DashboardWidgetResponse]", + serialize( + list( + DashboardWidget.objects.filter( + dashboard_id__in=[i.id for i in item_list] + ).order_by("id") + ), + user=user, ), - user=user, ) creator_ids = [d.created_by_id for d in item_list if d.created_by_id is not None] serialized_creators = ( user_service.serialize_many(filter={"user_ids": creator_ids}) if creator_ids else [] ) - serialized_users = {u["id"]: u for u in serialized_creators} + serialized_users = { + u["id"]: u for u in cast("list[dict[str, object]]", serialized_creators) + } for dashboard in item_list: dashboard_widgets = [w for w in widgets if w and w["dashboardId"] == str(dashboard.id)] @@ -729,23 +778,39 @@ class DashboardRevisionResponse(TypedDict): id: str title: str dateCreated: datetime - createdBy: dict[str, Any] | None + createdBy: dict[str, object] | None source: str @register(DashboardRevision) class DashboardRevisionSerializer(Serializer[DashboardRevisionResponse]): - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[DashboardRevision], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[DashboardRevision, dict[str, object]]: user_ids = [r.created_by_id for r in item_list if r.created_by_id is not None] users_by_id = { - u["id"]: u for u in user_service.serialize_many(filter={"user_ids": user_ids}) + u["id"]: u + for u in cast( + "list[dict[str, object]]", + user_service.serialize_many(filter={"user_ids": user_ids}), + ) } - return { + result: dict[DashboardRevision, dict[str, object]] = { revision: {"created_by": users_by_id.get(str(revision.created_by_id))} for revision in item_list } + return result - def serialize(self, obj, attrs, user, **kwargs) -> DashboardRevisionResponse: + def serialize( + self, + obj: DashboardRevision, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> DashboardRevisionResponse: created_by = attrs.get("created_by") or {} avatar = created_by.get("avatar") or {} avatar_type = avatar.get("avatarType") diff --git a/src/sentry/api/serializers/models/debug_file.py b/src/sentry/api/serializers/models/debug_file.py index a4597108bf01..4f0716fc26bb 100644 --- a/src/sentry/api/serializers/models/debug_file.py +++ b/src/sentry/api/serializers/models/debug_file.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from datetime import datetime from typing import Any, TypedDict @@ -22,7 +23,13 @@ class DebugFileSerializerResponse(TypedDict): @register(ProjectDebugFile) class DebugFileSerializer(Serializer[DebugFileSerializerResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> DebugFileSerializerResponse: + def serialize( + self, + obj: ProjectDebugFile, + attrs: Mapping[object, object], + user: object, + **kwargs: object, + ) -> DebugFileSerializerResponse: headers = {"Content-Type": obj.get_content_type()} return { diff --git a/src/sentry/api/serializers/models/discoversavedquery.py b/src/sentry/api/serializers/models/discoversavedquery.py index e3abd2f8b4cb..3d9cc332b957 100644 --- a/src/sentry/api/serializers/models/discoversavedquery.py +++ b/src/sentry/api/serializers/models/discoversavedquery.py @@ -1,7 +1,10 @@ from collections import defaultdict +from collections.abc import Sequence from datetime import datetime from typing import TypedDict +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.auth.principal import has_user from sentry.constants import ALL_ACCESS_PROJECTS @@ -14,6 +17,8 @@ ) from sentry.explore.models import ExploreSavedQuery, ExploreSavedQueryDataset from sentry.users.api.serializers.user import UserSerializerResponse +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service from sentry.utils.dates import outside_retention_with_modified_start, parse_timestamp @@ -36,8 +41,8 @@ class DiscoverSavedQueryResponseOptional(TypedDict, total=False): display: str topEvents: int interval: str - exploreQuery: dict - lastVisited: str + exploreQuery: dict | None + lastVisited: datetime | None starred: bool position: int | None @@ -50,9 +55,9 @@ class DiscoverSavedQueryResponse(DiscoverSavedQueryResponseOptional): queryDataset: str datasetSource: str expired: bool - dateCreated: str - dateUpdated: str - createdBy: UserSerializerResponse + dateCreated: datetime + dateUpdated: datetime + createdBy: UserSerializerResponse | None @register(DiscoverSavedQuery) @@ -99,8 +104,13 @@ def partial_serialize_explore_query(self, query: ExploreSavedQuery) -> dict: return data - def get_attrs(self, item_list, user, **kwargs): - result: defaultdict[str, dict] = defaultdict( + def get_attrs( + self, + item_list: Sequence[DiscoverSavedQuery], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> defaultdict[DiscoverSavedQuery, dict[str, object]]: + result: defaultdict[DiscoverSavedQuery, dict[str, object]] = defaultdict( lambda: {"created_by": {}, "explore_query": None} ) @@ -174,7 +184,13 @@ def get_attrs(self, item_list, user, **kwargs): return result - def serialize(self, obj, attrs, user, **kwargs) -> DiscoverSavedQueryResponse: + def serialize( + self, + obj: DiscoverSavedQuery, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> DiscoverSavedQueryResponse: query_keys = [ "environment", "query", diff --git a/src/sentry/api/serializers/models/environment.py b/src/sentry/api/serializers/models/environment.py index 4b73f375de15..764cbbadbd93 100644 --- a/src/sentry/api/serializers/models/environment.py +++ b/src/sentry/api/serializers/models/environment.py @@ -1,7 +1,12 @@ +from collections.abc import Mapping from typing import TypedDict +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.models.environment import Environment, EnvironmentProject +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class EnvironmentSerializerResponse(TypedDict): @@ -17,14 +22,24 @@ class EnvironmentProjectSerializerResponse(TypedDict): @register(Environment) class EnvironmentSerializer(Serializer[EnvironmentSerializerResponse]): - def serialize(self, obj: Environment, attrs, user, **kwargs) -> EnvironmentSerializerResponse: + def serialize( + self, + obj: Environment, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> EnvironmentSerializerResponse: return {"id": str(obj.id), "name": obj.name} @register(EnvironmentProject) class EnvironmentProjectSerializer(Serializer[EnvironmentProjectSerializerResponse]): def serialize( - self, obj: EnvironmentProject, attrs, user, **kwargs + self, + obj: EnvironmentProject, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, ) -> EnvironmentProjectSerializerResponse: return { "id": str(obj.id), diff --git a/src/sentry/api/serializers/models/event.py b/src/sentry/api/serializers/models/event.py index 91a2056cb370..ff5e1ccd4367 100644 --- a/src/sentry/api/serializers/models/event.py +++ b/src/sentry/api/serializers/models/event.py @@ -2,9 +2,9 @@ import re from collections import defaultdict -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from datetime import datetime, timezone -from typing import Any, NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict, cast import sentry_sdk import sqlparse @@ -14,6 +14,7 @@ from sentry import options from sentry.api.serializers import Serializer, register, serialize +from sentry.api.serializers.models.eventattachment import EventAttachmentSerializerResponse from sentry.api.serializers.models.release import GroupEventReleaseSerializer from sentry.api.serializers.models.userreport import UserReportSerializerResponse from sentry.api.serializers.types import GroupEventReleaseSerializerResponse @@ -63,7 +64,7 @@ class MeasurementValue(TypedDict): unit: NotRequired[str | None] -def get_crash_files(events): +def get_crash_files(events: Sequence[Event | GroupEvent]) -> list[EventAttachment]: event_ids = [x.event_id for x in events if x.platform == "native"] if event_ids: return [ @@ -74,7 +75,9 @@ def get_crash_files(events): return [] -def get_tags_with_meta(event) -> tuple[list[EventTag], dict[str, Any] | None]: +def get_tags_with_meta( + event: Event | GroupEvent, +) -> tuple[list[EventTag], dict[str, object] | None]: meta = get_path(event.data, "_meta", "tags") or {} # If we have meta, we need to get the tags in their original order @@ -120,7 +123,11 @@ def get_tags_with_meta(event) -> tuple[list[EventTag], dict[str, Any] | None]: return tags, meta_with_chunks(tags, tags_meta) -def get_entries(event: Event | GroupEvent, user: User, is_public: bool = False): +def get_entries( + event: Event | GroupEvent, + user: User | RpcUser | AnonymousUser, + is_public: bool = False, +) -> tuple[list[dict[str, object]], dict[int, dict[str, object]]]: # XXX(dcramer): These are called entries for future-proofing platform = event.platform meta = event.data.get("_meta") or {} @@ -164,20 +171,20 @@ class BaseEventSerializerResponse(TypedDict): location: str | None user: EventUserApiContext | None tags: list[EventTag] - platform: str + platform: str | None dateReceived: datetime | None - contexts: dict[str, Any] | None + contexts: dict[str, object] | None size: int | None entries: list[Any] dist: str | None sdk: EventSdkApiContext | None - context: dict[str, Any] | None - packages: dict[str, Any] + context: dict[str, object] | None + packages: dict[str, object] type: EventTypeStr - metadata: dict[str, Any] + metadata: dict[str, object] errors: list[EventErrorApiContext] occurrence: IssueOccurrenceResponse | None - _meta: dict[str, Any] + _meta: dict[str, object] class ErrorEventFields(TypedDict, total=False): @@ -207,7 +214,7 @@ class SqlFormatEventSerializerResponse(EventSerializerResponse): class IssueEventSerializerResponse(SqlFormatEventSerializerResponse): userReport: UserReportSerializerResponse | None - sdkUpdates: list[dict[str, Any]] + sdkUpdates: list[dict[str, object]] resolvedWith: list[str] @@ -258,17 +265,24 @@ def _get_legacy_message_with_meta(self, event): return (message, meta_with_chunks(message, msg_meta)) - def _get_user_report(self, user, event): + def _get_user_report( + self, user: User | RpcUser | AnonymousUser, event: Event | GroupEvent + ) -> UserReportSerializerResponse | None: try: user_report = UserReport.objects.get( event_id=event.event_id, project_id=event.project_id ) except UserReport.DoesNotExist: user_report = None - return serialize(user_report, user) + return cast(UserReportSerializerResponse | None, serialize(user_report, user)) - def get_attrs(self, item_list, user, **kwargs): - is_public = kwargs.pop("is_public", False) + def get_attrs( + self, + item_list: Sequence[Event | GroupEvent], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[Event | GroupEvent, dict[str, object]]: + is_public = cast(bool, kwargs.pop("is_public", False)) crash_files = get_crash_files(item_list) serialized_files = { file.event_id: serialized @@ -300,7 +314,7 @@ def get_attrs(self, item_list, user, **kwargs): } return results - def should_display_error(self, error): + def should_display_error(self, error: Mapping[str, object]) -> bool: name = error.get("name") if not isinstance(name, str): return True @@ -312,7 +326,13 @@ def should_display_error(self, error): and ".frames." not in name ) - def serialize(self, obj, attrs, user, **kwargs) -> EventSerializerResponse: + def serialize( + self, + obj: Event | GroupEvent, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> EventSerializerResponse: from sentry.api.serializers.rest_framework import convert_dict_key_case, snake_to_camel_case errors = [ @@ -405,7 +425,7 @@ def __serialize_transaction_attrs(self, attrs, obj) -> TransactionEventFields: return transaction_attrs - def __serialize_error_attrs(self, attrs, obj) -> ErrorEventFields: + def __serialize_error_attrs(self, attrs, obj: Event | GroupEvent) -> ErrorEventFields: """ Add attributes that are present on error and default event types """ @@ -427,12 +447,17 @@ def __init__(self) -> None: super().__init__() self.formatted_sql_cache: dict[str, str] = {} - def get_attrs(self, item_list, user, **kwargs): - is_public = kwargs.pop("is_public", False) + def get_attrs( + self, + item_list: Sequence[Event | GroupEvent], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[Event | GroupEvent, dict[str, object]]: + is_public = bool(kwargs.pop("is_public", False)) return super().get_attrs(item_list, user, is_public=is_public) # Various checks to ensure that we don't spend too much time formatting - def _should_skip_formatting(self, query: str): + def _should_skip_formatting(self, query: str) -> bool: if ( (not query) | (len(self.formatted_sql_cache) >= MAX_SQL_FORMAT_OPS) @@ -460,7 +485,10 @@ def _format_sql_query(self, message: str) -> str | None: return formatted def _format_breadcrumb_messages( - self, event_data: EventSerializerResponse, event: Event | GroupEvent, user: User + self, + event_data: EventSerializerResponse, + event: Event | GroupEvent, + user: User | RpcUser | AnonymousUser, ) -> EventSerializerResponse: try: breadcrumbs = next( @@ -485,7 +513,10 @@ def _format_breadcrumb_messages( return event_data def _get_release_info( - self, user, event, include_full_release_data: bool + self, + user: User | RpcUser | AnonymousUser, + event: Event | GroupEvent, + include_full_release_data: bool, ) -> GroupEventReleaseSerializerResponse | None: version = event.get_tag("sentry:release") if not version: @@ -504,7 +535,10 @@ def _get_release_info( return serialize(release, user, GroupEventReleaseSerializer()) def _format_db_spans( - self, event_data: EventSerializerResponse, event: Event | GroupEvent, user: User + self, + event_data: EventSerializerResponse, + event: Event | GroupEvent, + user: User | RpcUser | AnonymousUser, ) -> EventSerializerResponse: try: spans = next( @@ -525,8 +559,14 @@ def _format_db_spans( sentry_sdk.capture_exception(exc) return event_data - def serialize(self, obj, attrs, user, **kwargs) -> SqlFormatEventSerializerResponse: - include_full_release_data = kwargs.pop("include_full_release_data", False) + def serialize( + self, + obj: Event | GroupEvent, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs, + ) -> SqlFormatEventSerializerResponse: + include_full_release_data = bool(kwargs.pop("include_full_release_data", False)) result = super().serialize(obj, attrs, user, **kwargs) with traces.start_span(name="Format SQL", attributes={"sentry.op": "serialize"}): @@ -547,14 +587,14 @@ def get_attrs( item_list: Sequence[Event | GroupEvent], user: User | RpcUser | AnonymousUser, **kwargs, - ): - is_public = kwargs.pop("is_public", False) + ) -> dict[Event | GroupEvent, dict[str, object]]: + is_public = bool(kwargs.pop("is_public", False)) return super().get_attrs(item_list, user, is_public=is_public, **kwargs) - def _get_sdk_updates(self, obj): + def _get_sdk_updates(self, obj: Event | GroupEvent) -> list[dict[str, object]]: return list(get_suggested_updates(SdkSetupState.from_event_json(obj.data))) - def _get_resolved_with(self, obj: Event) -> list[str]: + def _get_resolved_with(self, obj: Event | GroupEvent) -> list[str]: stacktraces = find_stacktraces_in_data(obj.data) frame_lists = [stacktrace.get_frames() for stacktrace in stacktraces] @@ -569,7 +609,13 @@ def _get_resolved_with(self, obj: Event) -> list[str]: return list(unique_resolution_methods) - def serialize(self, obj, attrs, user, **kwargs) -> IssueEventSerializerResponse: + def serialize( + self, + obj: Event | GroupEvent, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> IssueEventSerializerResponse: result = super().serialize(obj, attrs, user, **kwargs) return { @@ -581,7 +627,12 @@ def serialize(self, obj, attrs, user, **kwargs) -> IssueEventSerializerResponse: class SharedEventSerializer(EventSerializer): - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[Event | GroupEvent], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[Event | GroupEvent, dict[str, object]]: return super().get_attrs(item_list, user, is_public=True, **kwargs) def serialize(self, obj, attrs, user, **kwargs): @@ -648,12 +699,22 @@ def get_attrs(self, item_list, user, **kwargs): crash_files = get_crash_files(item_list) serialized_files = { file.event_id: serialized - for file, serialized in zip(crash_files, serialize(crash_files, user=user)) + for file, serialized in zip( + crash_files, + cast( + list[EventAttachmentSerializerResponse], + serialize(crash_files, user=user), + ), + ) } return {event: {"crash_file": serialized_files.get(event.event_id)} for event in item_list} def serialize( # type: ignore[override] # intentionally different shape - self, obj: Event | GroupEvent, attrs, user, **kwargs + self, + obj: Event | GroupEvent, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, ) -> SimpleEventSerializerResponse: tags: list[EventTag] = [ {"key": key.split("sentry:", 1)[-1], "value": value} for key, value in obj.tags @@ -688,6 +749,20 @@ def serialize( # type: ignore[override] # intentionally different shape } +class ExternalEventSerializerResponse(TypedDict): + groupID: str | None + eventID: str + project: str + message: str + title: str + location: str | None + culprit: str | None + user: EventUserApiContext | None + tags: list[EventTag] + platform: str | None + datetime: str + + class ExternalEventSerializer(EventSerializer): """ Event serializer for the minimum event data needed to send to an external service. This @@ -702,7 +777,7 @@ def serialize(self, obj, attrs, user, **kwargs): query = convert_user_tag_to_query(tag["key"], tag["value"]) if query: tag["query"] = query - map_device_class_tags(tags) + map_device_class_tags(cast(list[EventTag], tags)) user = obj.get_minimal_user() @@ -723,7 +798,7 @@ def serialize(self, obj, attrs, user, **kwargs): } -def map_device_class_tags(tags): +def map_device_class_tags(tags: list[EventTag]) -> list[EventTag]: """ If device.class tag exists, set the value to high, medium, low """ diff --git a/src/sentry/api/serializers/models/eventattachment.py b/src/sentry/api/serializers/models/eventattachment.py index 35d5854bd677..e0a9fdbdfb8b 100644 --- a/src/sentry/api/serializers/models/eventattachment.py +++ b/src/sentry/api/serializers/models/eventattachment.py @@ -1,10 +1,15 @@ import mimetypes +from collections.abc import Mapping from datetime import datetime from typing import TypedDict +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.models.eventattachment import EventAttachment from sentry.models.files.file import File +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class EventAttachmentSerializerResponse(TypedDict): @@ -21,7 +26,13 @@ class EventAttachmentSerializerResponse(TypedDict): @register(EventAttachment) class EventAttachmentSerializer(Serializer[EventAttachmentSerializerResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> EventAttachmentSerializerResponse: + def serialize( + self, + obj: EventAttachment, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> EventAttachmentSerializerResponse: content_type = obj.content_type size = obj.size or 0 headers = {"Content-Type": content_type} diff --git a/src/sentry/api/serializers/models/eventuser.py b/src/sentry/api/serializers/models/eventuser.py index 394ec8f5bea1..7d397cd14dd8 100644 --- a/src/sentry/api/serializers/models/eventuser.py +++ b/src/sentry/api/serializers/models/eventuser.py @@ -1,28 +1,38 @@ from datetime import datetime from typing import TypedDict +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.utils.avatar import get_gravatar_url from sentry.utils.eventuser import EventUser class EventUserSerializerResponse(TypedDict): id: str | None - tagValue: str - identifier: str - username: str - email: str - name: str - ipAddress: str + tagValue: str | None + identifier: int | str | None + username: str | None + email: str | None + name: str | None + ipAddress: str | None avatarUrl: str - hash: str + hash: str | None # Legacy field, always null; typed nullable so it resolves in the OpenAPI schema. dateCreated: datetime | None @register(EventUser) class EventUserSerializer(Serializer[EventUserSerializerResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> EventUserSerializerResponse: + def serialize( + self, + obj: EventUser, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> EventUserSerializerResponse: return { "id": str(obj.id) if obj.id is not None else obj.id, "tagValue": obj.tag_value, diff --git a/src/sentry/api/serializers/models/exploresavedquery.py b/src/sentry/api/serializers/models/exploresavedquery.py index ab4eccaff2c0..495dfd55ee81 100644 --- a/src/sentry/api/serializers/models/exploresavedquery.py +++ b/src/sentry/api/serializers/models/exploresavedquery.py @@ -1,6 +1,9 @@ from collections import defaultdict +from collections.abc import Mapping from datetime import datetime -from typing import Literal, TypedDict +from typing import Literal, TypedDict, cast + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register from sentry.auth.principal import has_user @@ -12,6 +15,8 @@ ExploreSavedQueryStarred, ) from sentry.users.api.serializers.user import UserSerializerResponse +from sentry.users.models.user import User +from sentry.users.services.user import RpcUser from sentry.users.services.user.service import user_service from sentry.utils.dates import outside_retention_with_modified_start, parse_timestamp @@ -105,13 +110,20 @@ class ExploreSavedQueryResponse(ExploreSavedQueryResponseOptional): changedReason: ExploreSavedQueryChangedReasonType | None +class ExploreSavedQueryAttrs(TypedDict, total=False): + created_by: UserSerializerResponse | None + user_last_visited: datetime | None + starred: bool + position: int | None + + @register(ExploreSavedQuery) class ExploreSavedQueryModelSerializer(Serializer[ExploreSavedQueryResponse]): def get_attrs(self, item_list, user, **kwargs): result: defaultdict[str, dict] = defaultdict(lambda: {"created_by": {}}) - starred_queries: dict[int, int | None] = {} - user_last_visited: dict[int, datetime] = {} + starred_queries = {} + user_last_visited = {} if has_user(user): starred_queries = dict( ExploreSavedQueryStarred.objects.filter( @@ -158,7 +170,13 @@ def get_attrs(self, item_list, user, **kwargs): return result - def serialize(self, obj, attrs, user, **kwargs) -> ExploreSavedQueryResponse: + def serialize( + self, + obj: ExploreSavedQuery, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ExploreSavedQueryResponse: query_keys = [ "environment", "query", @@ -178,10 +196,10 @@ def serialize(self, obj, attrs, user, **kwargs) -> ExploreSavedQueryResponse: "expired": False, "dateAdded": obj.date_added, "dateUpdated": obj.date_updated, - "lastVisited": attrs.get("user_last_visited"), - "createdBy": attrs.get("created_by"), - "starred": attrs.get("starred"), - "position": attrs.get("position"), + "lastVisited": cast(datetime | None, attrs.get("user_last_visited")), + "createdBy": cast(UserSerializerResponse | None, attrs.get("created_by")), + "starred": cast(bool, attrs.get("starred")), + "position": cast(int | None, attrs.get("position")), "isPrebuilt": obj.prebuilt_id is not None, "changedReason": obj.changed_reason, } diff --git a/src/sentry/api/serializers/models/exporteddata.py b/src/sentry/api/serializers/models/exporteddata.py index 5a1667898374..f9911076a7ca 100644 --- a/src/sentry/api/serializers/models/exporteddata.py +++ b/src/sentry/api/serializers/models/exporteddata.py @@ -1,9 +1,14 @@ +from collections.abc import Sequence from datetime import datetime -from typing import Any, TypedDict +from typing import TypedDict + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register from sentry.data_export.base import ExportQueryType, ExportStatus from sentry.data_export.models import ExportedData +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service @@ -15,7 +20,7 @@ class ExportedDataUserResponse(TypedDict): class ExportedDataQueryResponse(TypedDict): type: str - info: dict[str, Any] + info: dict[str, object] class ExportedDataResponse(TypedDict): @@ -33,8 +38,13 @@ class ExportedDataResponse(TypedDict): @register(ExportedData) class ExportedDataSerializer(Serializer): - def get_attrs(self, item_list, user, **kwargs): - attrs = {} + def get_attrs( + self, + item_list: Sequence[ExportedData], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[ExportedData, dict[str, object]]: + attrs: dict[ExportedData, dict[str, object]] = {} serialized_users = { u["id"]: u for u in user_service.serialize_many( @@ -55,7 +65,13 @@ def get_attrs(self, item_list, user, **kwargs): attrs[item] = {} return attrs - def serialize(self, obj, attrs, user, **kwargs) -> ExportedDataResponse: + def serialize( + self, + obj: ExportedData, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ExportedDataResponse: file = obj._get_file() if file is None: checksum = None diff --git a/src/sentry/api/serializers/models/filechange.py b/src/sentry/api/serializers/models/filechange.py index 558692ed7ccb..15d71ef951f3 100644 --- a/src/sentry/api/serializers/models/filechange.py +++ b/src/sentry/api/serializers/models/filechange.py @@ -1,17 +1,38 @@ from __future__ import annotations -from typing import Any +from collections.abc import Mapping, Sequence +from typing import TypedDict, cast + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register -from sentry.api.serializers.models.commit import get_users_for_commits +from sentry.api.serializers.models.commit import EmptyAuthor, get_users_for_commits +from sentry.api.serializers.release_details_types import Author from sentry.models.commit import Commit from sentry.models.commitfilechange import CommitFileChange from sentry.models.repository import Repository +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser + + +class CommitFileChangeSerializerResponse(TypedDict): + id: str + orgId: int + author: Author | EmptyAuthor + commitMessage: str | None + filename: str + type: str + repoName: str | None @register(CommitFileChange) -class CommitFileChangeSerializer(Serializer): - def get_attrs(self, item_list, user, **kwargs): +class CommitFileChangeSerializer(Serializer[CommitFileChangeSerializerResponse]): + def get_attrs( + self, + item_list: Sequence[CommitFileChange], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[CommitFileChange, dict[str, object]]: commits = list( Commit.objects.filter(id__in=[f.commit_id for f in item_list]).select_related("author") ) @@ -24,7 +45,7 @@ def get_attrs(self, item_list, user, **kwargs): ).values_list("id", "name") ) - result: dict[int, Any] = {} + result: dict[CommitFileChange, dict[str, object]] = {} for item in item_list: commit = commits_by_id[item.commit_id] result[item] = { @@ -35,13 +56,20 @@ def get_attrs(self, item_list, user, **kwargs): return result - def serialize(self, obj, attrs, user, **kwargs): - return { + def serialize( + self, + obj: CommitFileChange, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> CommitFileChangeSerializerResponse: + data: CommitFileChangeSerializerResponse = { "id": str(obj.id), "orgId": obj.organization_id, - "author": attrs.get("user", {}), - "commitMessage": attrs.get("message", ""), + "author": cast(Author | EmptyAuthor, attrs.get("user", {})), + "commitMessage": cast(str | None, attrs.get("message", "")), "filename": obj.filename, "type": obj.type, - "repoName": attrs.get("repository_name", ""), + "repoName": cast(str | None, attrs.get("repository_name", "")), } + return data diff --git a/src/sentry/api/serializers/models/group.py b/src/sentry/api/serializers/models/group.py index a0bc3bd28ae5..c7d324f70c87 100644 --- a/src/sentry/api/serializers/models/group.py +++ b/src/sentry/api/serializers/models/group.py @@ -6,7 +6,7 @@ from collections import defaultdict from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, TypeGuard +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, TypeGuard, cast from django.conf import settings from django.contrib.auth.models import AnonymousUser @@ -84,7 +84,7 @@ def merge_list_dictionaries( dict1: MutableMapping[Any, list[Any]], dict2: Mapping[Any, Sequence[Any]] -): +) -> None: for key, val in dict2.items(): dict1.setdefault(key, []).extend(val) @@ -256,9 +256,9 @@ def _get_level_label(group: Group) -> GroupLevelStr: class GroupSerializerBase(Serializer, ABC): def __init__( self, - collapse=None, - expand=None, - ): + collapse: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> None: self.collapse = collapse self.expand = expand @@ -475,23 +475,23 @@ def serialize( @abstractmethod def _seen_stats_error( - self, error_issue_list: Sequence[Group], user + self, error_issue_list: Sequence[Group], user: User | RpcUser | AnonymousUser ) -> Mapping[Group, SeenStats]: pass @abstractmethod def _seen_stats_generic( - self, generic_issue_list: Sequence[Group], user + self, generic_issue_list: Sequence[Group], user: User | RpcUser | AnonymousUser ) -> Mapping[Group, SeenStats]: pass - def _expand(self, key) -> bool: + def _expand(self, key: str) -> bool: if self.expand is None: return False return key in self.expand - def _collapse(self, key) -> bool: + def _collapse(self, key: str) -> bool: if self.collapse is None: return False return key in self.collapse @@ -552,7 +552,9 @@ def _get_status(self, attrs: Mapping[str, Any], obj: Group): status_label = "unresolved" return status_details, status_label - def _get_seen_stats(self, item_list: Sequence[Group], user) -> Mapping[Group, SeenStats] | None: + def _get_seen_stats( + self, item_list: Sequence[Group], user: User | RpcUser | AnonymousUser + ) -> Mapping[Group, SeenStats] | None: """ Returns a dictionary keyed by item that includes: - times_seen @@ -583,11 +585,11 @@ def _get_seen_stats(self, item_list: Sequence[Group], user) -> Mapping[Group, Se def _get_group_snuba_stats( self, item_list: Sequence[Group], seen_stats: Mapping[Group, SeenStats] | None - ): + ) -> dict[int, dict[str, bool | None]] | None: if self._collapse("unhandled") and len(item_list) > 0: return None start = self._get_start_from_seen_stats(seen_stats) - unhandled = {} + unhandled: dict[int, bool | None] = {} cache_keys = [] for item in item_list: @@ -624,7 +626,7 @@ def _get_group_snuba_stats( {"organization_id": item_list[0].project.organization_id} if item_list else None ), ) - to_cache = {} + to_cache: dict[str, bool | None] = {} for x in rv["data"]: unhandled[x["group_id"]] = x["unhandled"] to_cache[f"group-mechanism-handled:{x['group_id']}"] = x["unhandled"] @@ -638,7 +640,7 @@ def _get_group_snuba_stats( return {group_id: {"unhandled": unhandled} for group_id, unhandled in unhandled.items()} @staticmethod - def _get_start_from_seen_stats(seen_stats: Mapping[Group, SeenStats] | None): + def _get_start_from_seen_stats(seen_stats: Mapping[Group, SeenStats] | None) -> datetime: # Try to figure out what is a reasonable time frame to look into stats, # based on a given "seen stats". We try to pick a day prior to the earliest last seen, # but it has to be at least 14 days, and not more than 90 days ago. @@ -710,7 +712,7 @@ def _get_subscriptions( @staticmethod def _resolve_resolutions( - groups: Sequence[Group], user + groups: Sequence[Group], user: User | RpcUser | AnonymousUser ) -> tuple[Mapping[int, Sequence[Any]], Mapping[int, Any]]: resolved_groups = [i for i in groups if i.status == GroupStatus.RESOLVED] if not resolved_groups: @@ -809,7 +811,7 @@ def _resolve_integration_annotations( return integration_annotations @staticmethod - def _get_permalink(attrs, obj: Group) -> str: + def _get_permalink(attrs: Mapping[str, object], obj: Group) -> str: with traces.start_span( name="GroupSerializerBase.serialize.permalink.build", attributes={"sentry.op": "GroupSerializerBase.serialize.permalink.build"}, @@ -855,14 +857,16 @@ def __call__( class GroupSerializer(GroupSerializerBase): def __init__( self, - collapse=None, - expand=None, + collapse: Sequence[str] | None = None, + expand: Sequence[str] | None = None, environment_func: Callable[[], Environment | None] | None = None, - ): + ) -> None: GroupSerializerBase.__init__(self, collapse=collapse, expand=expand) self.environment_func = environment_func if environment_func is not None else lambda: None - def _seen_stats_error(self, item_list, user) -> Mapping[Group, SeenStats]: + def _seen_stats_error( + self, item_list: Sequence[Group], user: User | RpcUser | AnonymousUser + ) -> Mapping[Group, SeenStats]: return self.__seen_stats_impl( item_list, tagstore.backend.get_groups_user_counts, @@ -870,7 +874,7 @@ def _seen_stats_error(self, item_list, user) -> Mapping[Group, SeenStats]: ) def _seen_stats_generic( - self, generic_issue_list: Sequence[Group], user + self, generic_issue_list: Sequence[Group], user: User | RpcUser | AnonymousUser ) -> Mapping[Group, SeenStats]: return self.__seen_stats_impl( generic_issue_list, @@ -1096,7 +1100,7 @@ def __init__( self.conditions = conditions def _seen_stats_error( - self, error_issue_list: Sequence[Group], user + self, error_issue_list: Sequence[Group], user: User | RpcUser | AnonymousUser ) -> Mapping[Group, SeenStats]: return self._parse_seen_stats_results( self._execute_error_seen_stats_query( @@ -1112,7 +1116,7 @@ def _seen_stats_error( ) def _seen_stats_generic( - self, generic_issue_list: Sequence[Group], user + self, generic_issue_list: Sequence[Group], user: User | RpcUser | AnonymousUser ) -> Mapping[Group, SeenStats]: return self._parse_seen_stats_results( self._execute_generic_seen_stats_query( @@ -1129,8 +1133,12 @@ def _seen_stats_generic( @staticmethod def _execute_error_seen_stats_query( - item_list, start=None, end=None, conditions=None, environment_ids=None - ): + item_list: Sequence[Group], + start: datetime | None = None, + end: datetime | None = None, + conditions: Sequence[Sequence[object]] | None = None, + environment_ids: list[int] | None = None, + ) -> dict[str, object]: project_ids = list({item.project_id for item in item_list}) group_ids = [item.id for item in item_list] aggregations = [ @@ -1144,25 +1152,32 @@ def _execute_error_seen_stats_query( if environment_ids: filters["environment"] = environment_ids - return aliased_query( - dataset=Dataset.Events, - start=start, - end=end, - groupby=["group_id"], - conditions=conditions, - filter_keys=filters, - aggregations=aggregations, - condition_resolver=get_snuba_column_name, - referrer="serializers.GroupSerializerSnuba._execute_error_seen_stats_query", - tenant_ids=( - {"organization_id": item_list[0].project.organization_id} if item_list else None + return cast( + dict[str, object], + aliased_query( + dataset=Dataset.Events, + start=start, + end=end, + groupby=["group_id"], + conditions=conditions, + filter_keys=filters, + aggregations=aggregations, + condition_resolver=get_snuba_column_name, + referrer="serializers.GroupSerializerSnuba._execute_error_seen_stats_query", + tenant_ids=( + {"organization_id": item_list[0].project.organization_id} if item_list else None + ), ), ) @staticmethod def _execute_generic_seen_stats_query( - item_list, start=None, end=None, conditions=None, environment_ids=None - ): + item_list: Sequence[Group], + start: datetime | None = None, + end: datetime | None = None, + conditions: Sequence[Sequence[object]] | None = None, + environment_ids: list[int] | None = None, + ) -> dict[str, object]: project_ids = list({item.project_id for item in item_list}) group_ids = [item.id for item in item_list] aggregations = [ @@ -1175,18 +1190,21 @@ def _execute_generic_seen_stats_query( if environment_ids: filters["environment"] = environment_ids - return aliased_query( - dataset=Dataset.IssuePlatform, - start=start, - end=end, - groupby=["group_id"], - conditions=conditions, - filter_keys=filters, - aggregations=aggregations, - condition_resolver=get_snuba_column_name, - referrer="serializers.GroupSerializerSnuba._execute_generic_seen_stats_query", - tenant_ids=( - {"organization_id": item_list[0].project.organization_id} if item_list else None + return cast( + dict[str, object], + aliased_query( + dataset=Dataset.IssuePlatform, + start=start, + end=end, + groupby=["group_id"], + conditions=conditions, + filter_keys=filters, + aggregations=aggregations, + condition_resolver=get_snuba_column_name, + referrer="serializers.GroupSerializerSnuba._execute_generic_seen_stats_query", + tenant_ids=( + {"organization_id": item_list[0].project.organization_id} if item_list else None + ), ), ) diff --git a/src/sentry/api/serializers/models/group_stream.py b/src/sentry/api/serializers/models/group_stream.py index b69ba5ac5f10..b4afaee39a37 100644 --- a/src/sentry/api/serializers/models/group_stream.py +++ b/src/sentry/api/serializers/models/group_stream.py @@ -3,14 +3,15 @@ import functools from abc import abstractmethod from collections import defaultdict -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from datetime import datetime, timedelta -from typing import Any, NamedTuple, NotRequired, Protocol, TypedDict +from typing import Any, NamedTuple, NotRequired, Protocol, TypedDict, cast from django.contrib.auth.models import AnonymousUser from django.utils import timezone from sentry import features, release_health, tsdb +from sentry.api.event_search import SearchFilter from sentry.api.serializers import serialize from sentry.api.serializers.models.actor import ActorSerializerResponse from sentry.api.serializers.models.group import ( @@ -85,8 +86,8 @@ def query_tsdb( groups: Sequence[Group], query_params, conditions=None, - environment_ids=None, - user=None, + environment_ids: list[int] | None = None, + user: User | RpcUser | AnonymousUser | None = None, **kwargs, ): pass @@ -149,7 +150,7 @@ def get_stats( class _MaybeStats(TypedDict, total=False): - stats: dict[str, dict[int, list[tuple[int, int]]]] + stats: dict[str, list[tuple[int, int]]] class StreamGroupSerializerResponse(BaseGroupSerializerResponse, _MaybeStats): @@ -157,7 +158,11 @@ class StreamGroupSerializerResponse(BaseGroupSerializerResponse, _MaybeStats): class StreamGroupSerializer(GroupSerializer, GroupStatsMixin): - def __init__(self, environment_func=None, stats_period=None): + def __init__( + self, + environment_func: Callable[[], Environment | None] | None = None, + stats_period: str | None = None, + ) -> None: super().__init__(environment_func=environment_func) assert ( @@ -197,7 +202,9 @@ def serialize( base = super().serialize(obj, attrs, user) if self.stats_period: - extra: _MaybeStats = {"stats": {self.stats_period: attrs["stats"]}} + extra: _MaybeStats = { + "stats": {self.stats_period: cast("list[tuple[int, int]]", attrs["stats"])} + } else: extra = {} @@ -208,8 +215,8 @@ def query_tsdb( groups: Sequence[Group], query_params, conditions=None, - environment_ids=None, - user=None, + environment_ids: list[int] | None = None, + user: User | RpcUser | AnonymousUser | None = None, **kwargs, ): try: @@ -232,11 +239,11 @@ def query_tsdb( class _SeenStatsFunc(Protocol): def __call__( self, - item_list, - start=None, - end=None, + item_list: Sequence[Group], + start: datetime | None = None, + end: datetime | None = None, conditions=None, - environment_ids=None, + environment_ids: list[int] | None = None, ) -> Mapping[str, Any]: ... @@ -308,17 +315,17 @@ class StreamGroupSerializerSnuba(GroupSerializerSnuba, GroupStatsMixin): def __init__( self, environment_ids: list[int] | None = None, - stats_period=None, - stats_period_start=None, - stats_period_end=None, - start=None, - end=None, - search_filters=None, - collapse=None, - expand=None, - organization_id=None, - project_ids=None, - ): + stats_period: str | None = None, + stats_period_start: datetime | None = None, + stats_period_end: datetime | None = None, + start: datetime | None = None, + end: datetime | None = None, + search_filters: Sequence[SearchFilter] | None = None, + collapse: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + organization_id: int | None = None, + project_ids: list[int] | None = None, + ) -> None: super().__init__( environment_ids, start, @@ -621,12 +628,12 @@ def query_tsdb( return results def _seen_stats_error( - self, error_issue_list: Sequence[Group], user + self, error_issue_list: Sequence[Group], user: User | RpcUser | AnonymousUser ) -> Mapping[Group, SeenStats]: return self.__seen_stats_impl(error_issue_list, self._execute_error_seen_stats_query) def _seen_stats_generic( - self, generic_issue_list: Sequence[Group], user + self, generic_issue_list: Sequence[Group], user: User | RpcUser | AnonymousUser ) -> Mapping[Group, SeenStats]: return self.__seen_stats_impl(generic_issue_list, self._execute_generic_seen_stats_query) @@ -682,7 +689,7 @@ def __seen_stats_impl( ) return time_range_result - def _build_session_cache_key(self, project_id): + def _build_session_cache_key(self, project_id: int) -> str: start_key_dt = end_key_dt = None env_key = "" if self.start: diff --git a/src/sentry/api/serializers/models/grouprelease.py b/src/sentry/api/serializers/models/grouprelease.py index 95a909143783..d0e610ca6729 100644 --- a/src/sentry/api/serializers/models/grouprelease.py +++ b/src/sentry/api/serializers/models/grouprelease.py @@ -1,25 +1,49 @@ -from collections import namedtuple -from datetime import timedelta +from collections.abc import Sequence +from datetime import datetime, timedelta +from typing import NamedTuple, TypedDict, cast +from django.contrib.auth.models import AnonymousUser from django.utils import timezone from sentry import tsdb from sentry.api.serializers import Serializer, register, serialize +from sentry.api.serializers.types import ReleaseSerializerResponse from sentry.models.grouprelease import GroupRelease from sentry.models.project import Project from sentry.models.release import Release from sentry.tsdb.base import TSDBModel +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser -StatsPeriod = namedtuple("StatsPeriod", ("segments", "interval")) + +class StatsPeriod(NamedTuple): + segments: int + interval: timedelta + + +class GroupReleaseSerializerResponse(TypedDict): + release: ReleaseSerializerResponse | None + environment: str + firstSeen: datetime + lastSeen: datetime + + +class GroupReleaseWithStatsSerializerResponse(GroupReleaseSerializerResponse): + stats: dict[str, list[tuple[float, float]]] @register(GroupRelease) -class GroupReleaseSerializer(Serializer): - def get_attrs(self, item_list, user, **kwargs): +class GroupReleaseSerializer(Serializer[GroupReleaseSerializerResponse]): + def get_attrs( + self, + item_list: Sequence[GroupRelease], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[GroupRelease, dict[str, object]]: release_list = list(Release.objects.filter(id__in=[i.release_id for i in item_list])) releases = {r.id: d for r, d in zip(release_list, serialize(release_list, user))} - result = {} + result: dict[GroupRelease, dict[str, object]] = {} for item in item_list: result[item] = {"release": releases.get(item.release_id)} return result @@ -39,11 +63,16 @@ class GroupReleaseWithStatsSerializer(GroupReleaseSerializer): "30d": StatsPeriod(30, timedelta(hours=24)), } - def __init__(self, since=None, until=None): + def __init__(self, since: datetime | None = None, until: datetime | None = None) -> None: self.since = since self.until = until - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[GroupRelease], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[GroupRelease, dict[str, object]]: attrs = super().get_attrs(item_list, user) tenant_ids = ( @@ -56,7 +85,7 @@ def get_attrs(self, item_list, user, **kwargs): else None ) - items: dict[str, list[str]] = {} + items: dict[int, list[int]] = {} for item in item_list: items.setdefault(item.group_id, []).append(item.id) attrs[item]["stats"] = {} @@ -80,9 +109,8 @@ def get_attrs(self, item_list, user, **kwargs): stats = {} for item in item_list: - attrs[item]["stats"][key] = [ - (k, v[item.id]) for k, v in stats.get(item.group_id, {}) - ] + item_stats = cast(dict[str, object], attrs[item]["stats"]) + item_stats[key] = [(k, v[item.id]) for k, v in stats.get(item.group_id, {})] return attrs def serialize(self, obj, attrs, user, **kwargs): diff --git a/src/sentry/api/serializers/models/groupsearchview.py b/src/sentry/api/serializers/models/groupsearchview.py index 4c1338f9e10c..38829b57c7ed 100644 --- a/src/sentry/api/serializers/models/groupsearchview.py +++ b/src/sentry/api/serializers/models/groupsearchview.py @@ -1,14 +1,19 @@ -from collections.abc import MutableMapping -from typing import Any, TypedDict +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import TypedDict, cast +from django.contrib.auth.models import AnonymousUser from django.db.models import prefetch_related_objects from sentry.api.serializers import Serializer, register from sentry.models.groupsearchview import GroupSearchView from sentry.models.groupsearchviewlastvisited import GroupSearchViewLastVisited from sentry.models.groupsearchviewstarred import GroupSearchViewStarred +from sentry.models.organization import Organization from sentry.models.savedsearch import SORT_LITERALS from sentry.users.api.serializers.user import UserSerializerResponse +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service @@ -28,38 +33,51 @@ class GroupSearchViewSerializerResponse(TypedDict): projects: list[int] environments: list[str] timeFilters: GroupSearchViewTimeFilters - lastVisited: str | None - dateCreated: str - dateUpdated: str + lastVisited: datetime | None + dateCreated: datetime | None + dateUpdated: datetime starred: bool stars: int @register(GroupSearchView) class GroupSearchViewSerializer(Serializer[GroupSearchViewSerializerResponse]): - def __init__(self, *args, **kwargs): - self.organization = kwargs.pop("organization", None) + def __init__(self, *args: object, **kwargs: object) -> None: + self.organization = cast(Organization | None, kwargs.pop("organization", None)) super().__init__(*args, **kwargs) - def get_attrs(self, item_list, user, **kwargs) -> MutableMapping[Any, Any]: + def get_attrs( + self, + item_list: Sequence[GroupSearchView], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[GroupSearchView, dict[str, object]]: prefetch_related_objects(item_list, "projects") - attrs: MutableMapping[Any, Any] = {} + attrs: dict[GroupSearchView, dict[str, object]] = {} - last_visited_views = GroupSearchViewLastVisited.objects.filter( - organization=self.organization, - user_id=user.id, - group_search_view_id__in=[item.id for item in item_list], - ) - user_starred_view_ids = set( - GroupSearchViewStarred.objects.filter( + last_visited_views = ( + GroupSearchViewLastVisited.objects.filter( organization=self.organization, user_id=user.id, - ).values_list("group_search_view_id", flat=True) + group_search_view_id__in=[item.id for item in item_list], + ) + if not isinstance(user, AnonymousUser) + else [] + ) + user_starred_view_ids = ( + set( + GroupSearchViewStarred.objects.filter( + organization=self.organization, + user_id=user.id, + ).values_list("group_search_view_id", flat=True) + ) + if not isinstance(user, AnonymousUser) + else set() ) last_visited_map = {lv.group_search_view_id: lv for lv in last_visited_views} - serialized_users = { - user["id"]: user + serialized_users: dict[str, dict[str, object]] = { + cast(str, user["id"]): cast("dict[str, object]", user) for user in user_service.serialize_many( filter={"user_ids": [view.user_id for view in item_list if view.user_id]}, as_user=user, @@ -77,21 +95,29 @@ def get_attrs(self, item_list, user, **kwargs) -> MutableMapping[Any, Any]: attrs[item]["created_by"] = serialized_users.get(str(item.user_id)) return attrs - def serialize(self, obj, attrs, user, **kwargs) -> GroupSearchViewSerializerResponse: + def serialize( + self, + obj: GroupSearchView, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> GroupSearchViewSerializerResponse: projects = [-1] if obj.is_all_projects else [p.id for p in obj.projects.all()] return { "id": str(obj.id), - "createdBy": attrs.get("created_by"), + "createdBy": cast(UserSerializerResponse | None, attrs.get("created_by")), "name": obj.name, "query": obj.query, - "querySort": obj.query_sort, + # Model choices constrain query_sort to SortOptions values, which + # exactly match SORT_LITERALS. + "querySort": cast(SORT_LITERALS, obj.query_sort), "projects": projects, "environments": obj.environments, "timeFilters": obj.time_filters, - "lastVisited": attrs.get("last_visited", None), - "starred": attrs.get("starred", False), - "stars": attrs.get("stars", 0), + "lastVisited": cast(datetime | None, attrs.get("last_visited", None)), + "starred": cast(bool, attrs.get("starred", False)), + "stars": cast(int, attrs.get("stars", 0)), "dateCreated": obj.date_added, "dateUpdated": obj.date_updated, } diff --git a/src/sentry/api/serializers/models/groupsearchviewstarred.py b/src/sentry/api/serializers/models/groupsearchviewstarred.py index 525f41708390..33d29cc5767d 100644 --- a/src/sentry/api/serializers/models/groupsearchviewstarred.py +++ b/src/sentry/api/serializers/models/groupsearchviewstarred.py @@ -1,33 +1,33 @@ -from typing import TypedDict +from collections.abc import Mapping, Sequence +from typing import cast + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register, serialize from sentry.api.serializers.models.groupsearchview import ( GroupSearchViewSerializer, + GroupSearchViewSerializerResponse, ) from sentry.models.groupsearchviewstarred import GroupSearchViewStarred -from sentry.models.savedsearch import SORT_LITERALS - +from sentry.models.organization import Organization +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser -class GroupSearchViewStarredSerializerResponse(TypedDict): - id: str - name: str - query: str - querySort: SORT_LITERALS - projects: list[int] - environments: list[str] - timeFilters: dict - lastVisited: str | None - dateCreated: str - dateUpdated: str +GroupSearchViewStarredSerializerResponse = GroupSearchViewSerializerResponse @register(GroupSearchViewStarred) class GroupSearchViewStarredSerializer(Serializer[GroupSearchViewStarredSerializerResponse]): - def __init__(self, *args, **kwargs): - self.organization = kwargs.pop("organization", None) + def __init__(self, *args: object, **kwargs: object) -> None: + self.organization = cast(Organization | None, kwargs.pop("organization", None)) super().__init__(*args, **kwargs) - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[GroupSearchViewStarred], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[GroupSearchViewStarred, GroupSearchViewSerializerResponse]: views = [item.group_search_view for item in item_list] serialized_views = serialize( views, @@ -36,5 +36,11 @@ def get_attrs(self, item_list, user, **kwargs): ) return dict(zip(item_list, serialized_views)) - def serialize(self, obj, attrs, user, **kwargs) -> GroupSearchViewStarredSerializerResponse: - return attrs + def serialize( + self, + obj: GroupSearchViewStarred, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> GroupSearchViewStarredSerializerResponse: + return cast(GroupSearchViewStarredSerializerResponse, attrs) diff --git a/src/sentry/api/serializers/models/grouptombstone.py b/src/sentry/api/serializers/models/grouptombstone.py index 38bf7cfb85d7..7a7ba792e105 100644 --- a/src/sentry/api/serializers/models/grouptombstone.py +++ b/src/sentry/api/serializers/models/grouptombstone.py @@ -1,12 +1,38 @@ +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import TypedDict, cast + +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.constants import LOG_LEVELS from sentry.models.grouptombstone import GroupTombstone +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service +class GroupTombstoneSerializerResponse(TypedDict): + id: str + level: str + message: str + culprit: str | None + type: str + metadata: dict[str, object] + actor: dict[str, object] + timesSeen: int + lastSeen: datetime | None + dateAdded: datetime | None + + @register(GroupTombstone) -class GroupTombstoneSerializer(Serializer): - def get_attrs(self, item_list, user, **kwargs): +class GroupTombstoneSerializer(Serializer[GroupTombstoneSerializerResponse]): + def get_attrs( + self, + item_list: Sequence[GroupTombstone], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[GroupTombstone, dict[str, object]]: user_list = user_service.serialize_many( filter={"user_ids": [item.actor_id for item in item_list if item.actor_id is not None]} ) @@ -17,7 +43,13 @@ def get_attrs(self, item_list, user, **kwargs): attrs[item] = {"user": users.get(item.actor_id, {})} return attrs - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: GroupTombstone, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> GroupTombstoneSerializerResponse: return { "id": str(obj.id), "level": LOG_LEVELS.get(obj.level, "unknown"), @@ -25,7 +57,7 @@ def serialize(self, obj, attrs, user, **kwargs): "culprit": obj.culprit, "type": obj.get_event_type(), "metadata": obj.get_event_metadata(), - "actor": attrs.get("user"), + "actor": cast(dict[str, object], attrs["user"]), "timesSeen": obj.times_seen, "lastSeen": obj.last_seen, "dateAdded": obj.date_added, diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index 85075985ed95..b2b31c7d6a7b 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -120,12 +120,12 @@ class OnboardingTasksSerializerResponse(TypedDict): status: str # TODO: literal/enum completionSeen: datetime | None dateCompleted: datetime - data: Any # JSON objec + data: object class OrganizationSummarySerializerResponseOptional(TypedDict, total=False): features: list[str] # Only included if include_feature_flags is True - extraOptions: dict[str, dict[str, Any]] + extraOptions: dict[str, dict[str, object]] access: frozenset[str] # Only if access=... is passed onboardingTasks: list[OnboardingTasksSerializerResponse] # Only if access=... is passed diff --git a/src/sentry/api/serializers/models/organization_access_request.py b/src/sentry/api/serializers/models/organization_access_request.py index 998cc5a80080..49ec40f74e1a 100644 --- a/src/sentry/api/serializers/models/organization_access_request.py +++ b/src/sentry/api/serializers/models/organization_access_request.py @@ -1,11 +1,33 @@ +from collections.abc import Mapping, Sequence +from typing import TypedDict, cast + +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register, serialize +from sentry.api.serializers.models.organization_member.response import OrganizationMemberResponse +from sentry.api.serializers.models.team import TeamSerializerResponse from sentry.models.organizationaccessrequest import OrganizationAccessRequest +from sentry.users.api.serializers.user import UserSerializerResponse +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service +class OrganizationAccessRequestSerializerResponse(TypedDict): + id: str + member: OrganizationMemberResponse | None + team: TeamSerializerResponse | None + requester: UserSerializerResponse | None + + @register(OrganizationAccessRequest) -class OrganizationAccessRequestSerializer(Serializer): - def get_attrs(self, item_list, user, **kwargs): +class OrganizationAccessRequestSerializer(Serializer[OrganizationAccessRequestSerializerResponse]): + def get_attrs( + self, + item_list: Sequence[OrganizationAccessRequest], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[OrganizationAccessRequest, dict[str, object]]: serialized_requesters = user_service.serialize_many( filter=dict(user_ids=[item.requester_id for item in item_list if item.requester_id]) ) @@ -27,18 +49,26 @@ def get_attrs(self, item_list, user, **kwargs): return { item: { - "requester": serialized_requesters_by_id.get(item.requester_id), + "requester": serialized_requesters_by_id.get(item.requester_id) + if item.requester_id is not None + else None, "member": serialized_members_by_id.get(item.member_id), "team": serialized_teams_by_id.get(item.team_id), } for item in item_list } - def serialize(self, obj, attrs, user, **kwargs): - serialized_access_request = { + def serialize( + self, + obj: OrganizationAccessRequest, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> OrganizationAccessRequestSerializerResponse: + serialized_access_request: OrganizationAccessRequestSerializerResponse = { "id": str(obj.id), - "member": attrs["member"], - "team": attrs["team"], - "requester": attrs["requester"], + "member": cast(OrganizationMemberResponse | None, attrs["member"]), + "team": cast(TeamSerializerResponse | None, attrs["team"]), + "requester": cast(UserSerializerResponse | None, attrs["requester"]), } return serialized_access_request diff --git a/src/sentry/api/serializers/models/organization_member/response.py b/src/sentry/api/serializers/models/organization_member/response.py index 1a35b54725bf..7a62bc5b463d 100644 --- a/src/sentry/api/serializers/models/organization_member/response.py +++ b/src/sentry/api/serializers/models/organization_member/response.py @@ -85,7 +85,7 @@ class OrganizationMemberResponse(TypedDict): expired: bool flags: _OrganizationMemberFlags dateCreated: datetime - inviteStatus: str + inviteStatus: str | None inviterName: str | None diff --git a/src/sentry/api/serializers/models/orgauthtoken.py b/src/sentry/api/serializers/models/orgauthtoken.py index 83644b4c4591..48a30c809cab 100644 --- a/src/sentry/api/serializers/models/orgauthtoken.py +++ b/src/sentry/api/serializers/models/orgauthtoken.py @@ -1,12 +1,37 @@ +from collections.abc import Mapping +from datetime import datetime +from typing import NotRequired, TypedDict, cast + +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.models.orgauthtoken import OrgAuthToken +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser + + +class OrgAuthTokenSerializerResponse(TypedDict): + id: str + name: str + scopes: list[str] + tokenLastCharacters: str | None + dateCreated: datetime + dateLastUsed: datetime | None + projectLastUsedId: str | None + token: NotRequired[str] @register(OrgAuthToken) -class OrgAuthTokenSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): - token = kwargs["token"] - data = { +class OrgAuthTokenSerializer(Serializer[OrgAuthTokenSerializerResponse]): + def serialize( + self, + obj: OrgAuthToken, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> OrgAuthTokenSerializerResponse: + token = cast(str | None, kwargs["token"]) + data: OrgAuthTokenSerializerResponse = { "id": str(obj.id), "name": obj.name, "scopes": obj.get_scopes(), diff --git a/src/sentry/api/serializers/models/profilechunkattachment.py b/src/sentry/api/serializers/models/profilechunkattachment.py index ce5c639bad5c..83b97fef3d52 100644 --- a/src/sentry/api/serializers/models/profilechunkattachment.py +++ b/src/sentry/api/serializers/models/profilechunkattachment.py @@ -2,8 +2,12 @@ from datetime import datetime from typing import Any, TypedDict +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.models.profilechunkattachment import ProfileChunkAttachment +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class ProfileChunkAttachmentSerializerResponse(TypedDict): @@ -21,7 +25,7 @@ def serialize( self, obj: ProfileChunkAttachment, attrs: Mapping[str, Any], - user: Any, + user: User | RpcUser | AnonymousUser, **kwargs: Any, ) -> ProfileChunkAttachmentSerializerResponse: return { diff --git a/src/sentry/api/serializers/models/project.py b/src/sentry/api/serializers/models/project.py index 579e57a84b03..e9e285bb1a01 100644 --- a/src/sentry/api/serializers/models/project.py +++ b/src/sentry/api/serializers/models/project.py @@ -3,7 +3,7 @@ from collections import defaultdict from collections.abc import Collection, Iterable, Mapping, MutableMapping, Sequence from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, Final, NotRequired, TypedDict +from typing import TYPE_CHECKING, Any, Final, NotRequired, Protocol, TypedDict, cast import orjson from django.conf import settings @@ -117,7 +117,7 @@ def _get_team_memberships( def get_access_by_project( projects: Sequence[Project], user: User | RpcUser | AnonymousUser -) -> dict[Project, dict[str, Any]]: +) -> dict[Project, dict[str, object]]: request = env.request project_teams = ProjectTeam.objects.filter(project__in=projects).values_list( @@ -141,7 +141,7 @@ def get_access_by_project( is_superuser = request and is_active_superuser(request) and request.user == user prefetch_related_objects(projects, "organization") - result: dict[Project, dict[str, Any]] = {} + result: dict[Project, dict[str, object]] = {} has_team_roles_cache: dict[int, bool] = {} with traces.start_span( name="project.check-access", attributes={"sentry.op": "project.check-access"} @@ -283,9 +283,9 @@ def get_has_trace_metrics(project: Project) -> bool: class _ProjectSerializerOptionalBaseResponse(TypedDict, total=False): - stats: Any - transactionStats: Any - sessionStats: Any + stats: list[tuple[int, int]] | dict[str, int] + transactionStats: list[tuple[int, int]] + sessionStats: CrashFreeRatesWithHealthData class ProjectSerializerBaseResponse(_ProjectSerializerOptionalBaseResponse): @@ -373,8 +373,8 @@ def _collapse(self, key: str) -> bool: return key in self.collapse def get_attrs( - self, item_list: Sequence[Project], user: User | RpcUser | AnonymousUser, **kwargs: Any - ) -> dict[Project, dict[str, Any]]: + self, item_list: Sequence[Project], user: User | RpcUser | AnonymousUser, **kwargs: object + ) -> dict[Project, dict[str, object]]: def measure_span(op_tag): span = traces.start_span( name=f"serialize.get_attrs.project.{op_tag}", @@ -588,7 +588,7 @@ def get_session_stats( return ret - def get_options(self, projects): + def get_options(self, projects: Sequence[Project]) -> dict[int, dict[str, object]]: # no options specified option_list: list[str] = [] @@ -600,7 +600,7 @@ def get_options(self, projects): queryset = ProjectOption.objects.filter(project__in=projects, key__in=option_list) - options_by_project: dict[int, dict[str, Any]] = defaultdict(dict) + options_by_project: dict[int, dict[str, object]] = defaultdict(dict) for option in queryset: options_by_project[option.project_id][option.key] = option.value @@ -668,8 +668,8 @@ def serialize( class ProjectWithOrganizationSerializer(ProjectSerializer): def get_attrs( - self, item_list: Sequence[Project], user: User | RpcUser | AnonymousUser, **kwargs: Any - ) -> dict[Project, dict[str, Any]]: + self, item_list: Sequence[Project], user: User | RpcUser | AnonymousUser, **kwargs: object + ) -> dict[Project, dict[str, object]]: attrs = super().get_attrs(item_list, user) orgs = {d["id"]: d for d in serialize(list({i.organization for i in item_list}), user)} @@ -698,8 +698,8 @@ class ProjectWithTeamResponseDict(ProjectSerializerResponse, _MaybeTeam): class ProjectWithTeamSerializer(ProjectSerializer): def get_attrs( - self, item_list: Sequence[Project], user: User | RpcUser | AnonymousUser, **kwargs: Any - ) -> dict[Project, dict[str, Any]]: + self, item_list: Sequence[Project], user: User | RpcUser | AnonymousUser, **kwargs: object + ) -> dict[Project, dict[str, object]]: attrs = super().get_attrs(item_list, user) project_teams = list( @@ -745,7 +745,7 @@ class LatestReleaseDict(TypedDict): class _OrganizationProjectOptionalResponse(TypedDict, total=False): latestDeploys: dict[str, dict[str, str]] | None - options: dict[str, Any] + options: dict[str, object] class OrganizationProjectResponse( @@ -765,11 +765,13 @@ class _DeployDict(TypedDict): class ProjectSummarySerializer(ProjectWithTeamSerializer): - def __init__(self, access: Access | None = None, **kwargs): + def __init__(self, access: Access | None = None, **kwargs) -> None: self.access = access super().__init__(**kwargs) - def get_deploys_by_project(self, item_list) -> dict[int, dict[str, _DeployDict]]: + def get_deploys_by_project( + self, item_list: Sequence[Project] + ) -> dict[int, dict[str, _DeployDict]]: cursor = connection.cursor() cursor.execute( """ @@ -815,8 +817,8 @@ def get_deploys_by_project(self, item_list) -> dict[int, dict[str, _DeployDict]] return deploys_by_project def get_attrs( - self, item_list: Sequence[Project], user: User | RpcUser | AnonymousUser, **kwargs: Any - ) -> dict[Project, dict[str, Any]]: + self, item_list: Sequence[Project], user: User | RpcUser | AnonymousUser, **kwargs: object + ) -> dict[Project, dict[str, object]]: attrs = super().get_attrs(item_list, user) projects_with_user_reports = set( @@ -908,7 +910,7 @@ def serialize( # type: ignore[override] # intentionally different data shape return context -def bulk_fetch_project_latest_releases(projects: Sequence[Project]): +def bulk_fetch_project_latest_releases(projects: Sequence[Project]) -> list[Release]: """ Fetches the latest release for each of the passed projects :param projects: @@ -958,6 +960,13 @@ def bulk_fetch_project_latest_releases(projects: Sequence[Project]): ) +class _ReleaseWithActualProjectId(Protocol): + """Release row from bulk_fetch_project_latest_releases carrying the raw project id.""" + + version: str + actual_project_id: int + + def _get_project_to_release_version_mapping( item_list: Sequence[Project], ) -> dict[int, dict[str, str]]: @@ -965,8 +974,11 @@ def _get_project_to_release_version_mapping( Return mapping of project_ID -> release version for the latest release in each project """ return { + # Raw SQL above selects project_id as actual_project_id. release.actual_project_id: {"version": release.version} - for release in bulk_fetch_project_latest_releases(item_list) + for release in cast( + list[_ReleaseWithActualProjectId], bulk_fetch_project_latest_releases(item_list) + ) } @@ -979,10 +991,10 @@ class Plugin(TypedDict): canDisable: bool isTestable: bool hasConfiguration: bool - metadata: dict + metadata: dict[str, object] contexts: list[str] status: str - assets: list + assets: list[object] doc: str firstPartyAlternative: Any deprecationDate: Any @@ -1000,7 +1012,7 @@ class Plugin(TypedDict): class DetailedProjectResponse(ProjectWithTeamResponseDict): latestRelease: LatestReleaseDict | None - options: dict[str, Any] + options: dict[str, object] digestsMinDelay: int digestsMaxDelay: int subjectPrefix: str @@ -1019,7 +1031,7 @@ class DetailedProjectResponse(ProjectWithTeamResponseDict): scrapeJavaScript: bool enableAutoReleaseCreation: bool highlightTags: list[str] - highlightContext: dict[str, Any] + highlightContext: dict[str, object] highlightPreset: HighlightPreset groupingConfig: str derivedGroupingEnhancements: str @@ -1047,12 +1059,12 @@ class DetailedProjectResponse(ProjectWithTeamResponseDict): class DetailedProjectSerializer(ProjectWithTeamSerializer): def get_attrs( - self, item_list: Sequence[Project], user: User | RpcUser | AnonymousUser, **kwargs: Any - ) -> dict[Project, dict[str, Any]]: + self, item_list: Sequence[Project], user: User | RpcUser | AnonymousUser, **kwargs: object + ) -> dict[Project, dict[str, object]]: attrs = super().get_attrs(item_list, user) queryset = ProjectOption.objects.filter(project__in=item_list, key__in=OPTION_KEYS) - options_by_project: dict[int, dict[str, Any]] = defaultdict(dict) + options_by_project: dict[int, dict[str, object]] = defaultdict(dict) for option in queryset.iterator(): options_by_project[option.project_id][option.key] = option.value @@ -1217,7 +1229,7 @@ def serialize( return data - def format_options(self, attrs: Mapping[str, Any]) -> dict[str, Any]: + def format_options(self, attrs: Mapping[str, Any]) -> dict[str, object]: options = attrs["options"] return { @@ -1333,7 +1345,13 @@ def get_value_with_default(self, attrs, key): class SharedProjectSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: Project, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser | None, + **kwargs: object, + ) -> dict[str, object]: return { "slug": obj.slug, "name": obj.name, diff --git a/src/sentry/api/serializers/models/project_key.py b/src/sentry/api/serializers/models/project_key.py index 266395d21df2..161bd293a81b 100644 --- a/src/sentry/api/serializers/models/project_key.py +++ b/src/sentry/api/serializers/models/project_key.py @@ -1,6 +1,9 @@ from collections.abc import Mapping from datetime import datetime -from typing import Any, NotRequired, TypedDict +from typing import NotRequired, TypedDict, cast + +from django.contrib.auth.models import AnonymousUser +from rest_framework.request import Request from sentry.api.serializers import Serializer, register from sentry.auth.superuser import is_active_superuser @@ -10,6 +13,8 @@ ) from sentry.loader.dynamic_sdk_options import DynamicSdkLoaderOption, get_dynamic_sdk_loader_option from sentry.models.projectkey import ProjectKey +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class RateLimit(TypedDict): @@ -69,7 +74,11 @@ class ProjectKeySerializerResponse(TypedDict): @register(ProjectKey) class ProjectKeySerializer(Serializer[ProjectKeySerializerResponse]): def serialize( - self, obj: ProjectKey, attrs: Mapping[str, Any], user: Any, **kwargs: Any + self, + obj: ProjectKey, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, ) -> ProjectKeySerializerResponse: # obj.public_key should always be set but it isn't required in the ProjectKey model. # Because of this mypy complains that ProjectKeySerializerResponse attrs id, name, and label @@ -123,7 +132,7 @@ def serialize( }, } - request = kwargs.get("request") + request = cast(Request, kwargs.get("request")) if request and is_active_superuser(request): data["useCase"] = obj.use_case diff --git a/src/sentry/api/serializers/models/project_platform.py b/src/sentry/api/serializers/models/project_platform.py index 33dd8c509930..4fe00171b1e3 100644 --- a/src/sentry/api/serializers/models/project_platform.py +++ b/src/sentry/api/serializers/models/project_platform.py @@ -1,14 +1,33 @@ +from collections.abc import Mapping +from datetime import datetime +from typing import TypedDict + +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.models.projectplatform import ProjectPlatform +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser + + +class ProjectPlatformSerializerResponse(TypedDict): + platform: str + dateCreated: datetime @register(ProjectPlatform) -class ProjectPlatformSerializer(Serializer): +class ProjectPlatformSerializer(Serializer[ProjectPlatformSerializerResponse]): """ Tracks usage of a platform for a given project. Note: This model is used solely for analytics. """ - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: ProjectPlatform, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ProjectPlatformSerializerResponse: return {"platform": obj.platform, "dateCreated": obj.date_added} diff --git a/src/sentry/api/serializers/models/project_repository.py b/src/sentry/api/serializers/models/project_repository.py index 96d2f50a298b..679bf442e4bf 100644 --- a/src/sentry/api/serializers/models/project_repository.py +++ b/src/sentry/api/serializers/models/project_repository.py @@ -1,10 +1,13 @@ from collections.abc import Mapping, Sequence -from typing import Any, NotRequired, TypedDict +from typing import NotRequired, TypedDict +from django.contrib.auth.models import AnonymousUser from django.db.models import prefetch_related_objects from sentry.api.serializers import Serializer, register from sentry.models.projectrepository import ProjectRepository +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class ProjectRepositorySerializerResponse(TypedDict): @@ -26,17 +29,20 @@ def __init__(self, include_mapping_count: bool = False) -> None: self.include_mapping_count = include_mapping_count def get_attrs( - self, item_list: Sequence[ProjectRepository], user: Any, **kwargs: Any - ) -> dict[ProjectRepository, dict[str, Any]]: + self, + item_list: Sequence[ProjectRepository], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[ProjectRepository, dict[str, object]]: prefetch_related_objects(item_list, "repository") return {item: {} for item in item_list} def serialize( self, obj: ProjectRepository, - attrs: Mapping[str, Any], - user: Any, - **kwargs: Any, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, ) -> ProjectRepositorySerializerResponse: repository = obj.repository provider = repository.provider diff --git a/src/sentry/api/serializers/models/project_transaction_threshold.py b/src/sentry/api/serializers/models/project_transaction_threshold.py index 16196e8b7cf4..0cec3b2a6264 100644 --- a/src/sentry/api/serializers/models/project_transaction_threshold.py +++ b/src/sentry/api/serializers/models/project_transaction_threshold.py @@ -1,14 +1,42 @@ +from collections.abc import Mapping +from datetime import datetime +from typing import TypedDict + +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.models.transaction_threshold import ( TRANSACTION_METRICS, ProjectTransactionThreshold, ProjectTransactionThresholdOverride, ) +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser + + +class ProjectTransactionThresholdResponse(TypedDict): + id: str + threshold: str + metric: str + projectId: str + editedBy: str + dateUpdated: datetime + dateAdded: datetime | None + + +class ProjectTransactionThresholdOverrideResponse(ProjectTransactionThresholdResponse): + transaction: str @register(ProjectTransactionThreshold) -class ProjectTransactionThresholdSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): +class ProjectTransactionThresholdSerializer(Serializer[ProjectTransactionThresholdResponse]): + def serialize( + self, + obj: ProjectTransactionThreshold, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser | None, + **kwargs: object, + ) -> ProjectTransactionThresholdResponse: return { "id": str(obj.id), "threshold": str(obj.threshold), @@ -21,8 +49,16 @@ def serialize(self, obj, attrs, user, **kwargs): @register(ProjectTransactionThresholdOverride) -class ProjectTransactionThresholdOverrideSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): +class ProjectTransactionThresholdOverrideSerializer( + Serializer[ProjectTransactionThresholdOverrideResponse] +): + def serialize( + self, + obj: ProjectTransactionThresholdOverride, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser | None, + **kwargs: object, + ) -> ProjectTransactionThresholdOverrideResponse: return { "id": str(obj.id), "threshold": str(obj.threshold), diff --git a/src/sentry/api/serializers/models/projectcodeowners.py b/src/sentry/api/serializers/models/projectcodeowners.py index fa5601094c2a..ff0bfa62ac2b 100644 --- a/src/sentry/api/serializers/models/projectcodeowners.py +++ b/src/sentry/api/serializers/models/projectcodeowners.py @@ -1,7 +1,10 @@ import logging +from collections.abc import Collection, Sequence from datetime import datetime from typing import TypedDict +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register, serialize from sentry.api.serializers.models.projectownership import ( OwnershipRuleOwnerResponse, @@ -17,6 +20,8 @@ from sentry.integrations.source_code_management.repository import RepositoryIntegration from sentry.issues.ownership.grammar import OwnershipSchema, convert_schema_to_rules_text from sentry.models.projectcodeowners import ProjectCodeOwners +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser logger = logging.getLogger(__name__) @@ -76,12 +81,17 @@ class ProjectCodeOwnersResponse(ProjectCodeOwnersResponseOptional): class ProjectCodeOwnersSerializer(Serializer[ProjectCodeOwnersResponse]): def __init__( self, - expand=None, - ): + expand: Collection[str] | None = None, + ) -> None: self.expand = expand or [] - def get_attrs(self, item_list, user, **kwargs): - attrs = {} + def get_attrs( + self, + item_list: Sequence[ProjectCodeOwners], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[ProjectCodeOwners, dict[str, object]]: + attrs: dict[ProjectCodeOwners, dict[str, object]] = {} integrations = { i.id: i for i in integration_service.get_integrations( @@ -120,7 +130,13 @@ def get_attrs(self, item_list, user, **kwargs): return attrs - def serialize(self, obj, attrs, user, **kwargs) -> ProjectCodeOwnersResponse: + def serialize( + self, + obj: ProjectCodeOwners, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ProjectCodeOwnersResponse: data: ProjectCodeOwnersResponse = { "id": str(obj.id), "raw": obj.raw, diff --git a/src/sentry/api/serializers/models/projectownership.py b/src/sentry/api/serializers/models/projectownership.py index 5f4a64c31f15..be778a290a4c 100644 --- a/src/sentry/api/serializers/models/projectownership.py +++ b/src/sentry/api/serializers/models/projectownership.py @@ -1,9 +1,14 @@ +from collections.abc import Mapping from datetime import datetime from typing import NotRequired, TypedDict +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.issues.ownership.grammar import OwnershipRuleMatcher from sentry.models.projectownership import ProjectOwnership +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class OwnershipRuleOwnerResponse(TypedDict): @@ -31,18 +36,24 @@ class ProjectOwnershipResponseOptional(TypedDict, total=False): # JSON object representing this serializer in API response class ProjectOwnershipResponse(ProjectOwnershipResponseOptional): - raw: str + raw: str | None fallthrough: bool - dateCreated: datetime - lastUpdated: datetime + dateCreated: datetime | None + lastUpdated: datetime | None isActive: bool autoAssignment: str - codeownersAutoSync: bool + codeownersAutoSync: bool | None @register(ProjectOwnership) class ProjectOwnershipSerializer(Serializer[ProjectOwnershipResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> ProjectOwnershipResponse: + def serialize( + self, + obj: ProjectOwnership, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser | None, + **kwargs: object, + ) -> ProjectOwnershipResponse: assignment = ( "Auto Assign to Suspect Commits" if obj.auto_assignment and obj.suspect_committer_auto_assignment diff --git a/src/sentry/api/serializers/models/pullrequest.py b/src/sentry/api/serializers/models/pullrequest.py index fa86ada0ac1b..a0e5f655deb5 100644 --- a/src/sentry/api/serializers/models/pullrequest.py +++ b/src/sentry/api/serializers/models/pullrequest.py @@ -2,6 +2,7 @@ from datetime import datetime from typing import Literal, TypedDict +from django.contrib.auth.models import AnonymousUser from django.db.models import Prefetch, prefetch_related_objects from sentry.api.serializers import Serializer, register, serialize @@ -22,6 +23,8 @@ ) from sentry.models.repository import Repository from sentry.pr_metrics.attribution import is_seer_attribution +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser PullRequestStatus = Literal["merged", "open", "closed", "draft", "unknown"] @@ -113,7 +116,13 @@ def get_attrs(self, item_list, user, **kwargs): return result - def serialize(self, obj: PullRequest, attrs, user, **kwargs) -> PullRequestSerializerResponse: + def serialize( + self, + obj: PullRequest, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> PullRequestSerializerResponse: return { "id": obj.key, "title": obj.title, @@ -182,7 +191,13 @@ def get_attrs(self, item_list, user, **kwargs): attrs[item]["attribution"] = _serialize_attribution(item.valid_attributions) return attrs - def serialize(self, obj: PullRequest, attrs, user, **kwargs) -> LinkedPullRequestResponse: + def serialize( + self, + obj: PullRequest, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> LinkedPullRequestResponse: checks_and_review = self.checks_and_review_by_pr_id.get(obj.id, PullRequestStatusResult()) return { **super().serialize(obj, attrs, user, **kwargs), diff --git a/src/sentry/api/serializers/models/recentsearch.py b/src/sentry/api/serializers/models/recentsearch.py index e008a18d9aba..21923a062ce8 100644 --- a/src/sentry/api/serializers/models/recentsearch.py +++ b/src/sentry/api/serializers/models/recentsearch.py @@ -1,10 +1,33 @@ +from collections.abc import Mapping +from datetime import datetime +from typing import TypedDict + +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.models.recentsearch import RecentSearch +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser + + +class RecentSearchSerializerResponse(TypedDict): + id: str + organizationId: str + type: int + query: str + lastSeen: datetime + dateCreated: datetime @register(RecentSearch) -class RecentSearchSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): +class RecentSearchSerializer(Serializer[RecentSearchSerializerResponse]): + def serialize( + self, + obj: RecentSearch, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> RecentSearchSerializerResponse: return { "id": str(obj.id), "organizationId": str(obj.organization_id), diff --git a/src/sentry/api/serializers/models/relayusage.py b/src/sentry/api/serializers/models/relayusage.py index c13d2a7fd03a..df82d4b2bc7c 100644 --- a/src/sentry/api/serializers/models/relayusage.py +++ b/src/sentry/api/serializers/models/relayusage.py @@ -1,8 +1,13 @@ +from collections.abc import Mapping from datetime import datetime from typing import TypedDict +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register from sentry.models.relay import RelayUsage +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class OrganizationRelayResponse(TypedDict): @@ -14,8 +19,14 @@ class OrganizationRelayResponse(TypedDict): @register(RelayUsage) -class RelayUsageSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs) -> OrganizationRelayResponse: +class RelayUsageSerializer(Serializer[OrganizationRelayResponse]): + def serialize( + self, + obj: RelayUsage, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> OrganizationRelayResponse: return { "relayId": obj.relay_id, "version": obj.version, diff --git a/src/sentry/api/serializers/models/release.py b/src/sentry/api/serializers/models/release.py index 13687a0277a5..5b552c7f32f0 100644 --- a/src/sentry/api/serializers/models/release.py +++ b/src/sentry/api/serializers/models/release.py @@ -3,15 +3,21 @@ import datetime from collections import defaultdict from collections.abc import Mapping, Sequence -from typing import Any, NotRequired, TypedDict +from typing import NotRequired, TypedDict from django.contrib.auth.models import AnonymousUser from django.core.cache import cache -from django.db.models import Sum +from django.db.models import QuerySet, Sum from sentry import release_health, tagstore from sentry.api.serializers import Serializer, register, serialize -from sentry.api.serializers.release_details_types import Author, NonMappableUser, VersionInfo +from sentry.api.serializers.release_details_types import ( + Author, + HealthData, + NonMappableUser, + VersionInfo, +) +from sentry.api.serializers.release_details_types import Project as SerializedProject from sentry.api.serializers.types import ( GroupEventReleaseSerializerResponse, ReleaseSerializerResponse, @@ -20,9 +26,10 @@ from sentry.models.commit import Commit from sentry.models.commitauthor import CommitAuthor from sentry.models.deploy import Deploy +from sentry.models.project import Project from sentry.models.projectplatform import ProjectPlatform from sentry.models.release import Release, ReleaseStatus -from sentry.models.releaseprojectenvironment import ReleaseProjectEnvironment +from sentry.models.releaseprojectenvironment import AdoptionStage, ReleaseProjectEnvironment from sentry.models.releases.release_project import ReleaseProject from sentry.release_health.base import ReleaseHealthOverview from sentry.users.api.serializers.user import UserSerializerResponse @@ -57,7 +64,7 @@ def expose_version_info(info) -> VersionInfo | None: } -def _expose_health_data(data): +def _expose_health_data(data: ReleaseHealthOverview | None) -> HealthData | None: if not data: return None return { @@ -81,8 +88,8 @@ def _expose_health_data(data): } -def _expose_project(project): - rv = { +def _expose_project(project: _ProjectDict) -> SerializedProject: + rv: SerializedProject = { "id": project["id"], "slug": project["slug"], "name": project["name"], @@ -97,8 +104,8 @@ def _expose_project(project): return rv -def _expose_current_project_meta(current_project_meta): - rv = {} +def _expose_current_project_meta(current_project_meta: dict[str, object]) -> dict[str, object]: + rv: dict[str, object] = {} if "sessions_lower_bound" in current_project_meta: rv["sessionsLowerBound"] = current_project_meta["sessions_lower_bound"] if "sessions_upper_bound" in current_project_meta: @@ -119,7 +126,7 @@ class _AuthorList(TypedDict): def _get_authors_metadata( - item_list: list[Release], user: User | RpcUser | AnonymousUser + item_list: Sequence[Release], user: User | RpcUser | AnonymousUser ) -> dict[Release, _AuthorList]: """ Returns a dictionary of release_id => authors metadata, @@ -168,7 +175,9 @@ def _get_authors_metadata( return result -def _get_last_commit_metadata(item_list, user): +def _get_last_commit_metadata( + item_list: Sequence[Release], user: User | RpcUser | AnonymousUser +) -> dict[Release, dict[str, object]]: """ Returns a dictionary of release_id => commit metadata, where each commit metadata dict contains last_commit. @@ -187,7 +196,7 @@ def _get_last_commit_metadata(item_list, user): else: commits = {} - result = {} + result: dict[Release, dict[str, object]] = {} for item in item_list: result[item] = { "last_commit": commits.get(item.last_commit_id), @@ -195,7 +204,9 @@ def _get_last_commit_metadata(item_list, user): return result -def _get_last_deploy_metadata(item_list, user): +def _get_last_deploy_metadata( + item_list: Sequence[Release], user: User | RpcUser | AnonymousUser +) -> dict[Release, dict[str, object]]: """ Returns a dictionary of release_id => deploy metadata, where each commit metadata dict contains last_deploy @@ -215,7 +226,7 @@ def _get_last_deploy_metadata(item_list, user): else: deploys = {} - result = {} + result: dict[Release, dict[str, object]] = {} for item in item_list: result[item] = {"last_deploy": deploys.get(item.last_deploy_id)} return result @@ -409,7 +420,7 @@ def get_users_for_authors( class _ProjectDict(TypedDict): id: int - slug: str | None + slug: str name: str new_groups: int | None platform: str | None @@ -420,7 +431,7 @@ class _ProjectDict(TypedDict): @register(Release) class ReleaseSerializer(Serializer): - def __get_project_id_list(self, item_list) -> list[int]: + def __get_project_id_list(self, item_list: Sequence[Release]) -> list[int]: project_ids = set() need_fallback = False @@ -479,8 +490,10 @@ def __get_release_data_no_environment(self, project, item_list, no_snuba_for_rel return first_seen, last_seen, group_counts_by_release - def _get_release_adoption_stages(self, release_project_envs): - adoption_stages: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict) + def _get_release_adoption_stages( + self, release_project_envs: QuerySet[ReleaseProjectEnvironment] + ) -> dict[str, dict[str, AdoptionStage]]: + adoption_stages: dict[str, dict[str, AdoptionStage]] = defaultdict(dict) for release_project_env in release_project_envs: adoption_stages[release_project_env.release.version].setdefault( @@ -489,7 +502,11 @@ def _get_release_adoption_stages(self, release_project_envs): return adoption_stages - def __get_release_data_with_environments(self, release_project_envs): + def __get_release_data_with_environments( + self, release_project_envs: QuerySet[ReleaseProjectEnvironment] + ) -> tuple[ + dict[str, datetime.datetime], dict[str, datetime.datetime], dict[int, dict[int, int]] + ]: first_seen: dict[str, datetime.datetime] = {} last_seen: dict[str, datetime.datetime] = {} @@ -515,7 +532,12 @@ def __get_release_data_with_environments(self, release_project_envs): return first_seen, last_seen, group_counts_by_release - def _get_release_project_envs(self, item_list, environments, project): + def _get_release_project_envs( + self, + item_list: Sequence[Release], + environments: Sequence[str] | None, + project: Project | None, + ) -> QuerySet[ReleaseProjectEnvironment]: release_project_envs = ( ReleaseProjectEnvironment.objects.filter(release__in=item_list) .select_related("release", "project") @@ -528,7 +550,12 @@ def _get_release_project_envs(self, item_list, environments, project): return release_project_envs - def _get_release_project_envs_unordered(self, item_list, environments, project): + def _get_release_project_envs_unordered( + self, + item_list: Sequence[Release], + environments: Sequence[str] | None, + project: Project | None, + ) -> QuerySet[ReleaseProjectEnvironment]: release_project_envs = ReleaseProjectEnvironment.objects.filter( release__in=item_list ).select_related("release") @@ -562,7 +589,7 @@ def get_attrs(self, item_list, user, **kwargs): if with_health_data and no_snuba_for_release_creation: raise TypeError("health data requires snuba") - adoption_stages = {} + adoption_stages: dict[str, dict[str, AdoptionStage]] = {} if self.with_adoption_stages: release_project_envs = self._get_release_project_envs(item_list, environments, project) adoption_stages = self._get_release_adoption_stages(release_project_envs) @@ -581,7 +608,7 @@ def get_attrs(self, item_list, user, **kwargs): issue_counts_by_release, ) = self.__get_release_data_with_environments(release_project_envs) - owners = {} + owners: dict[str, object] = {} owner_ids = [i.owner_id for i in item_list if i.owner_id] if owner_ids: owners = { @@ -615,6 +642,7 @@ def get_attrs(self, item_list, user, **kwargs): platforms_by_project[project_id].append(platform) # XXX: Legacy should be removed later + has_health_data: dict[tuple[int, str], bool] = {} if with_health_data: health_data = release_health.backend.get_release_health_data_overview( [(pr["project__id"], pr["release__version"]) for pr in project_releases], @@ -623,10 +651,8 @@ def get_attrs(self, item_list, user, **kwargs): environments=environments, stat=health_stat, ) - has_health_data = None else: health_data = None - has_health_data = {} for pr in project_releases: # Use environment-filtered new groups count if available, otherwise fall back to ReleaseProject data @@ -660,7 +686,7 @@ def get_attrs(self, item_list, user, **kwargs): ) in has_health_data release_projects[pr["release_id"]].append(pr_rv) - result = {} + result: dict[Release, dict[str, object]] = {} for item in item_list: single_release_projects = release_projects.get(item.id, []) @@ -674,7 +700,7 @@ def get_attrs(self, item_list, user, **kwargs): else: release_new_groups = sum((issue_counts_by_release.get(item.id) or {}).values()) - p = { + p: dict[str, object] = { "owner": owners[str(item.owner_id)] if item.owner_id else None, "new_groups": release_new_groups, "projects": single_release_projects, @@ -695,7 +721,13 @@ def get_attrs(self, item_list, user, **kwargs): result[item] = p return result - def serialize(self, obj, attrs, user, **kwargs) -> ReleaseSerializerResponse: + def serialize( + self, + obj: Release, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs, + ) -> ReleaseSerializerResponse: d: ReleaseSerializerResponse = { "id": obj.id, "version": obj.version, @@ -736,20 +768,31 @@ class GroupEventReleaseSerializer(Serializer): The minimal representation of a release necessary for group events """ - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[Release], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[Release, dict[str, object]]: last_commit_metadata_attrs = _get_last_commit_metadata(item_list, user) deploy_metadata_attrs = _get_last_deploy_metadata(item_list, user) - result = {} + result: dict[Release, dict[str, object]] = {} for item in item_list: - p = {} + p: dict[str, object] = {} p.update(last_commit_metadata_attrs[item]) p.update(deploy_metadata_attrs[item]) result[item] = p return result - def serialize(self, obj, attrs, user, **kwargs) -> GroupEventReleaseSerializerResponse: + def serialize( + self, + obj: Release, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> GroupEventReleaseSerializerResponse: return { "id": obj.id, "commitCount": obj.commit_count, diff --git a/src/sentry/api/serializers/models/release_file.py b/src/sentry/api/serializers/models/release_file.py index 1da430e1e5c9..77d8b89c97ec 100644 --- a/src/sentry/api/serializers/models/release_file.py +++ b/src/sentry/api/serializers/models/release_file.py @@ -1,19 +1,23 @@ from base64 import urlsafe_b64decode, urlsafe_b64encode from datetime import datetime -from typing import Any, TypedDict +from typing import TypedDict + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register from sentry.models.distribution import Distribution from sentry.models.releasefile import ReleaseFile +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class ReleaseFileSerializerResponse(TypedDict): id: str name: str dist: str | None - headers: dict[str, Any] + headers: dict[str, object] size: int - sha1: str + sha1: str | None dateCreated: datetime @@ -47,7 +51,13 @@ def decode_release_file_id(id: str): @register(ReleaseFile) class ReleaseFileSerializer(Serializer[ReleaseFileSerializerResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> ReleaseFileSerializerResponse: + def serialize( + self, + obj: ReleaseFile, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ReleaseFileSerializerResponse: dist_name = None if obj.dist_id: dist_name = Distribution.objects.get(pk=obj.dist_id).name diff --git a/src/sentry/api/serializers/models/release_threshold.py b/src/sentry/api/serializers/models/release_threshold.py index 45e28d090b58..2772e0da59f9 100644 --- a/src/sentry/api/serializers/models/release_threshold.py +++ b/src/sentry/api/serializers/models/release_threshold.py @@ -1,14 +1,41 @@ +from collections.abc import Mapping +from datetime import datetime +from typing import TypedDict + +from django.contrib.auth.models import AnonymousUser + from sentry.api.serializers import Serializer, register, serialize from sentry.models.release_threshold.constants import ( THRESHOLD_TYPE_INT_TO_STR, TRIGGER_TYPE_INT_TO_STR, + ThresholdTypeName, + TriggerTypeName, ) from sentry.models.release_threshold.release_threshold import ReleaseThreshold +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser + + +class ReleaseThresholdSerializerResponse(TypedDict): + id: str + threshold_type: ThresholdTypeName + trigger_type: TriggerTypeName + value: int + window_in_seconds: int + project: object + environment: object | None + date_added: datetime @register(ReleaseThreshold) -class ReleaseThresholdSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): +class ReleaseThresholdSerializer(Serializer[ReleaseThresholdSerializerResponse]): + def serialize( + self, + obj: ReleaseThreshold, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ReleaseThresholdSerializerResponse: return { "id": str(obj.id), "threshold_type": THRESHOLD_TYPE_INT_TO_STR[obj.threshold_type], diff --git a/src/sentry/api/serializers/models/repository.py b/src/sentry/api/serializers/models/repository.py index 2ffc5d79b1be..0cdf1464b08a 100644 --- a/src/sentry/api/serializers/models/repository.py +++ b/src/sentry/api/serializers/models/repository.py @@ -1,10 +1,14 @@ from collections.abc import Mapping, Sequence from datetime import datetime -from typing import Any, TypedDict +from typing import Any, TypedDict, cast + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register from sentry.models.repository import Repository from sentry.models.repositorysettings import RepositorySettings +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class RepositorySettingsSerializerResponse(TypedDict): @@ -31,7 +35,11 @@ class RepositorySerializerResponse(RepositorySerializerResponseOptional): @register(RepositorySettings) class RepositorySettingsSerializer(Serializer[RepositorySettingsSerializerResponse]): def serialize( - self, obj: RepositorySettings, attrs: Mapping[str, Any], user: Any, **kwargs: Any + self, + obj: RepositorySettings, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, ) -> RepositorySettingsSerializerResponse: return { "enabledCodeReview": obj.enabled_code_review, @@ -49,7 +57,10 @@ def _expand(self, key: str) -> bool: return key in self.expand def get_attrs( - self, item_list: Sequence[Repository], user: Any, **kwargs: Any + self, + item_list: Sequence[Repository], + user: User | RpcUser | AnonymousUser, + **kwargs: object, ) -> dict[Repository, dict[str, Any]]: result: dict[Repository, dict[str, Any]] = {repo: {} for repo in item_list} @@ -65,7 +76,11 @@ def get_attrs( return result def serialize( - self, obj: Repository, attrs: Mapping[str, Any], user: Any, **kwargs: Any + self, + obj: Repository, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, ) -> RepositorySerializerResponse: external_slug = None integration_id = None @@ -91,7 +106,7 @@ def serialize( } if self._expand("settings"): - settings = attrs.get("settings") + settings = cast(RepositorySettings | None, attrs.get("settings")) if settings: data["settings"] = RepositorySettingsSerializer().serialize(settings, {}, user) else: diff --git a/src/sentry/api/serializers/models/role.py b/src/sentry/api/serializers/models/role.py index c7df74dd9abc..af8308fbcdbb 100644 --- a/src/sentry/api/serializers/models/role.py +++ b/src/sentry/api/serializers/models/role.py @@ -1,5 +1,5 @@ from collections.abc import Collection, Mapping -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from django.contrib.auth.models import AnonymousUser @@ -52,16 +52,16 @@ def _serialize_base_role( class OrganizationRoleSerializer(Serializer[OrganizationRoleSerializerResponse]): - def __init__(self, **kwargs): + def __init__(self, **kwargs: object) -> None: """ Remove this when deleting "organizations:team-roles" flag """ - self.organization = kwargs["organization"] + self.organization = cast(Organization, kwargs["organization"]) def serialize( self, obj: OrganizationRole, - attrs: Mapping[str, Any], + attrs: Mapping[str, object], user: User | RpcUser | AnonymousUser, **kwargs: Any, ) -> OrganizationRoleSerializerResponse: @@ -77,15 +77,23 @@ def serialize( class TeamRoleSerializer(Serializer[TeamRoleSerializerResponse]): - def __init__(self, **kwargs): + def __init__(self, **kwargs: object) -> None: """ Remove this when deleting "organizations:team-roles" flag """ - self.organization = kwargs["organization"] + self.organization = cast(Organization, kwargs["organization"]) - def serialize(self, obj: TeamRole, attrs, user, **kwargs) -> TeamRoleSerializerResponse: + def serialize( + self, + obj: TeamRole, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> TeamRoleSerializerResponse: base = _serialize_base_role( - obj, self.organization, allowed_roles=kwargs.get("allowed_roles", ()) + obj, + self.organization, + allowed_roles=cast(Collection[Role], kwargs.get("allowed_roles", ())), ) return { **base, diff --git a/src/sentry/api/serializers/models/seer_agentic_triage_run.py b/src/sentry/api/serializers/models/seer_agentic_triage_run.py index ed4304036cbf..79ff7c5bcbcf 100644 --- a/src/sentry/api/serializers/models/seer_agentic_triage_run.py +++ b/src/sentry/api/serializers/models/seer_agentic_triage_run.py @@ -6,6 +6,7 @@ from datetime import datetime from typing import Any, TypedDict +from django.contrib.auth.models import AnonymousUser from django.db.models import Prefetch, prefetch_related_objects from sentry.api.serializers import Serializer, register, serialize @@ -26,6 +27,8 @@ SeerWorkflowStrategy, ) from sentry.seer.workflows.schemas import WorkflowRunSource +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class SeerAgenticTriageRunResultResponse(TypedDict): @@ -59,7 +62,11 @@ class SeerWorkflowRunExecutionResponse(TypedDict): class SeerWorkflowRunExecutionSerializer(Serializer[SeerWorkflowRunExecutionResponse]): def serialize( - self, obj: SeerWorkflowRunExecution, attrs: Mapping[str, Any], user: Any, **kwargs: Any + self, + obj: SeerWorkflowRunExecution, + attrs: Mapping[str, Any], + user: User | RpcUser | AnonymousUser, + **kwargs: Any, ) -> SeerWorkflowRunExecutionResponse: return {"seerRunId": str(obj.seer_run.uuid) if obj.seer_run is not None else None} @@ -84,7 +91,7 @@ class SeerAgenticTriageRunSerializer(Serializer[SeerAgenticTriageRunResponse]): def get_attrs( self, item_list: Sequence[SeerWorkflowRun], - user: Any, + user: User | RpcUser | AnonymousUser, accessible_project_ids: Collection[int] | None = None, **kwargs: Any, ) -> dict[SeerWorkflowRun, dict[str, Any]]: @@ -181,7 +188,7 @@ def serialize( self, obj: SeerWorkflowRun, attrs: Mapping[str, Any], - user: Any, + user: User | RpcUser | AnonymousUser, **kwargs: Any, ) -> SeerAgenticTriageRunResponse: blocked_group_ids = attrs.get("blocked_group_ids", ()) diff --git a/src/sentry/api/serializers/models/seer_run.py b/src/sentry/api/serializers/models/seer_run.py index 43fdec753deb..f7e08b007fa5 100644 --- a/src/sentry/api/serializers/models/seer_run.py +++ b/src/sentry/api/serializers/models/seer_run.py @@ -2,7 +2,9 @@ from collections import defaultdict from collections.abc import Mapping, Sequence -from typing import Any, NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict, cast + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register, serialize from sentry.api.serializers.models.pullrequest import ( @@ -10,6 +12,8 @@ PullRequestSerializerResponse, ) from sentry.seer.models.run import SeerAgentRun, SeerRun, SeerRunPullRequest +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser # Within a run, outputs are ordered to match the questions that produced them @@ -47,7 +51,7 @@ class SeerRunResponse(TypedDict): @register(SeerRun) class SeerRunSerializer(Serializer): def get_attrs( - self, item_list: Sequence[SeerRun], user: Any, **kwargs: Any + self, item_list: Sequence[SeerRun], user: User | RpcUser | AnonymousUser, **kwargs: object ) -> dict[SeerRun, dict[str, Any]]: # The reverse one-to-one accessor (``run.agent``) raises DoesNotExist when # absent, so map the agent rows explicitly rather than dereferencing it. @@ -83,10 +87,14 @@ def get_attrs( } def serialize( - self, obj: SeerRun, attrs: Mapping[str, Any], user: Any, **kwargs: Any + self, + obj: SeerRun, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, ) -> SeerRunResponse: - agent: SeerAgentRun | None = attrs.get("agent") - pull_requests = attrs.get("pull_requests", []) + agent = cast(SeerAgentRun | None, attrs.get("agent")) + pull_requests = cast(list[PullRequestSerializerResponse], attrs.get("pull_requests", [])) return { "id": str(obj.uuid), "type": obj.type, diff --git a/src/sentry/api/serializers/models/tagvalue.py b/src/sentry/api/serializers/models/tagvalue.py index cbb0dfc528e5..7469f1eaa106 100644 --- a/src/sentry/api/serializers/models/tagvalue.py +++ b/src/sentry/api/serializers/models/tagvalue.py @@ -1,29 +1,46 @@ -from typing import Any, cast +from collections.abc import Mapping, Sequence +from typing import cast + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer from sentry.search.utils import convert_user_tag_to_query +from sentry.tagstore.types import GroupTagValue +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.utils.eventuser import EventUser class UserTagValueSerializer(Serializer): - def __init__(self, project_id): + def __init__(self, project_id: int) -> None: self.project_id = project_id - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[GroupTagValue], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[GroupTagValue, dict[str, EventUser | None]]: users = EventUser.for_tags(project_id=self.project_id, values=[t.value for t in item_list]) - result = {} + result: dict[GroupTagValue, dict[str, EventUser | None]] = {} for item in item_list: - result[item] = {"user": users.get(item.value)} + result[item] = {"user": users.get(item.value) if item.value is not None else None} return result - def serialize(self, obj, attrs, user, **kwargs): - result: dict[str, Any] = {} + def serialize( + self, + obj: GroupTagValue, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[str, object]: + result: dict[str, object] = {} if isinstance(attrs["user"], EventUser): - result = cast(dict[str, Any], attrs["user"].serialize()) + result = cast(dict[str, object], attrs["user"].serialize()) else: result = {"id": None} - query = convert_user_tag_to_query("user", obj.value) + query = convert_user_tag_to_query("user", obj.value) if obj.value is not None else None if query: result["query"] = query diff --git a/src/sentry/api/serializers/models/team.py b/src/sentry/api/serializers/models/team.py index eda9e7c59997..876bb42c8165 100644 --- a/src/sentry/api/serializers/models/team.py +++ b/src/sentry/api/serializers/models/team.py @@ -137,7 +137,7 @@ class BaseTeamSerializerResponse(TypedDict): dateCreated: datetime | None isMember: bool teamRole: str | None - flags: dict[str, Any] + flags: dict[str, bool] access: frozenset[str] # scopes granted by teamRole hasAccess: bool isPending: bool @@ -172,7 +172,7 @@ def __init__( collapse: Sequence[str] | None = None, expand: Sequence[str] | None = None, access: Access | None = None, - ): + ) -> None: self.collapse = collapse self.expand = expand self.access = access diff --git a/src/sentry/api/serializers/models/trace_item_attribute_context.py b/src/sentry/api/serializers/models/trace_item_attribute_context.py index ad538f02e214..ec20115cd504 100644 --- a/src/sentry/api/serializers/models/trace_item_attribute_context.py +++ b/src/sentry/api/serializers/models/trace_item_attribute_context.py @@ -1,5 +1,8 @@ +from collections.abc import Mapping from datetime import datetime -from typing import Any, TypedDict +from typing import TypedDict + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register from sentry.explore.models import ( @@ -7,6 +10,8 @@ TraceItemAttributeTypes, TraceItemTypes, ) +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class TraceItemAttributeContextResponse(TypedDict): @@ -25,7 +30,11 @@ class TraceItemAttributeContextResponse(TypedDict): @register(TraceItemAttributeContext) class TraceItemAttributeContextSerializer(Serializer): def serialize( - self, obj: TraceItemAttributeContext, attrs: Any, user: Any, **kwargs: Any + self, + obj: TraceItemAttributeContext, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, ) -> TraceItemAttributeContextResponse: return { "id": str(obj.id), diff --git a/src/sentry/api/serializers/models/trace_item_attribute_value_context.py b/src/sentry/api/serializers/models/trace_item_attribute_value_context.py index ca34eecaf45b..e33a9585e139 100644 --- a/src/sentry/api/serializers/models/trace_item_attribute_value_context.py +++ b/src/sentry/api/serializers/models/trace_item_attribute_value_context.py @@ -1,5 +1,8 @@ +from collections.abc import Mapping from datetime import datetime -from typing import Any, TypedDict +from typing import TypedDict + +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register from sentry.explore.models import ( @@ -7,6 +10,8 @@ TraceItemTypes, TraceMetricTypes, ) +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class TraceItemAttributeValueContextResponse(TypedDict): @@ -24,7 +29,11 @@ class TraceItemAttributeValueContextResponse(TypedDict): @register(TraceItemAttributeValueContext) class TraceItemAttributeValueContextSerializer(Serializer): def serialize( - self, obj: TraceItemAttributeValueContext, attrs: Any, user: Any, **kwargs: Any + self, + obj: TraceItemAttributeValueContext, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, ) -> TraceItemAttributeValueContextResponse: return { "id": str(obj.id), diff --git a/src/sentry/api/serializers/models/user_social_auth.py b/src/sentry/api/serializers/models/user_social_auth.py index abd4b35804c5..092adb6da0a0 100644 --- a/src/sentry/api/serializers/models/user_social_auth.py +++ b/src/sentry/api/serializers/models/user_social_auth.py @@ -1,6 +1,12 @@ +from collections.abc import Mapping +from typing import TypedDict + from django.conf import settings +from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer, register +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from social_auth.models import UserSocialAuth @@ -8,9 +14,21 @@ def get_provider_label(obj: UserSocialAuth) -> str: return settings.AUTH_PROVIDER_LABELS[obj.provider] +class UserSocialAuthSerializerResponse(TypedDict): + id: str + provider: str + providerLabel: str + + @register(UserSocialAuth) -class UserSocialAuthSerializer(Serializer): - def serialize(self, obj, attrs, user, **kwargs): +class UserSocialAuthSerializer(Serializer[UserSocialAuthSerializerResponse]): + def serialize( + self, + obj: UserSocialAuth, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> UserSocialAuthSerializerResponse: return { "id": str(obj.id), "provider": obj.provider, diff --git a/src/sentry/api/serializers/models/userreport.py b/src/sentry/api/serializers/models/userreport.py index 2664c2850add..2beb543cd95e 100644 --- a/src/sentry/api/serializers/models/userreport.py +++ b/src/sentry/api/serializers/models/userreport.py @@ -1,10 +1,13 @@ -from datetime import timedelta -from typing import Any, TypedDict +from collections.abc import Callable, Sequence +from datetime import datetime, timedelta +from typing import NotRequired, TypedDict +from django.contrib.auth.models import AnonymousUser from django.utils import timezone from sentry import quotas from sentry.api.serializers import Serializer, register, serialize +from sentry.models.environment import Environment from sentry.models.group import Group from sentry.models.project import Project from sentry.models.userreport import UserReport @@ -12,6 +15,8 @@ from sentry.services import eventstore from sentry.services.eventstore.models import Event from sentry.snuba.dataset import Dataset +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.utils.eventuser import EventUser, SerializedEventUser @@ -26,23 +31,29 @@ class UserReportSerializerResponse(TypedDict): name: str | None email: str | None comments: str - dateCreated: str + dateCreated: datetime user: SerializedEventUser | None event: UserReportEvent class UserReportWithGroupSerializerResponse(UserReportSerializerResponse): - issue: dict[str, Any] + issue: dict[str, object] class _EventUser(TypedDict): event_user: EventUser | dict[None, None] + group: NotRequired[dict[str, object] | None] @register(UserReport) -class UserReportSerializer(Serializer): - def get_attrs(self, item_list, user, **kwargs): - attrs: dict[str, _EventUser] = {} +class UserReportSerializer(Serializer[UserReportSerializerResponse]): + def get_attrs( + self, + item_list: Sequence[UserReport], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[UserReport, _EventUser]: + attrs: dict[UserReport, _EventUser] = {} project = Project.objects.get(id=item_list[0].project_id) retention = quotas.backend.get_event_retention(organization=project.organization) @@ -72,19 +83,25 @@ def get_attrs(self, item_list, user, **kwargs): return attrs - def serialize(self, obj, attrs, user, **kwargs) -> UserReportSerializerResponse: + def serialize( + self, + obj: UserReport, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> UserReportSerializerResponse: # TODO(dcramer): add in various context from the event # context == user / http / extra interfaces - name = obj.name or obj.email - email = obj.email - user = None + name: str | None = obj.name or obj.email + email: str | None = obj.email + serialized_user: SerializedEventUser | None = None if attrs["event_user"]: event_user = attrs["event_user"] if isinstance(event_user, EventUser): name = name or event_user.name email = email or event_user.email - user = event_user.serialize() + serialized_user = event_user.serialize() return { "id": str(obj.id), "eventID": obj.event_id, @@ -92,16 +109,21 @@ def serialize(self, obj, attrs, user, **kwargs) -> UserReportSerializerResponse: "email": email, "comments": obj.comments, "dateCreated": obj.date_added, - "user": user, + "user": serialized_user, "event": {"id": obj.event_id, "eventID": obj.event_id}, } class UserReportWithGroupSerializer(UserReportSerializer): - def __init__(self, environment_func=None): + def __init__(self, environment_func: Callable[[], Environment | None] | None = None) -> None: self.environment_func = environment_func - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[UserReport], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[UserReport, _EventUser]: from sentry.api.serializers import GroupSerializer groups = list(Group.objects.filter(id__in={i.group_id for i in item_list if i.group_id})) @@ -123,7 +145,13 @@ def get_attrs(self, item_list, user, **kwargs): ) return attrs - def serialize(self, obj, attrs, user, **kwargs) -> UserReportWithGroupSerializerResponse: + def serialize( + self, + obj: UserReport, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> UserReportWithGroupSerializerResponse: context = super().serialize(obj, attrs, user) return { **context, diff --git a/src/sentry/api/serializers/release_details_types.py b/src/sentry/api/serializers/release_details_types.py index a69531b92a8d..816fffc160e3 100644 --- a/src/sentry/api/serializers/release_details_types.py +++ b/src/sentry/api/serializers/release_details_types.py @@ -1,6 +1,8 @@ +from collections.abc import Mapping from datetime import datetime -from typing import Any, TypedDict +from typing import Any, TypeAlias, TypedDict +from sentry.release_health.base import ReleaseHealthStats, StatsPeriod from sentry.users.api.serializers.user import UserSerializerResponse @@ -31,7 +33,7 @@ class NonMappableUser(TypedDict): email: str -Author = UserSerializerResponse | NonMappableUser +Author: TypeAlias = UserSerializerResponse | NonMappableUser class HealthDataOptional(TypedDict, total=False): @@ -53,7 +55,7 @@ class HealthData(HealthDataOptional): sessionsCrashed: int sessionsErrored: int hasHealthData: bool - stats: dict[str, Any] + stats: Mapping[StatsPeriod, ReleaseHealthStats] | None class ProjectOptional(TypedDict, total=False): @@ -73,4 +75,4 @@ class BaseProject(ProjectOptional): class Project(BaseProject): - newGroups: int + newGroups: int | None diff --git a/src/sentry/api/serializers/rest_framework/commit.py b/src/sentry/api/serializers/rest_framework/commit.py index 269581f2c2fe..f6624a282df5 100644 --- a/src/sentry/api/serializers/rest_framework/commit.py +++ b/src/sentry/api/serializers/rest_framework/commit.py @@ -7,7 +7,7 @@ class CommitPatchSetSerializer(serializers.Serializer): path = serializers.CharField(max_length=510) type = serializers.CharField(max_length=1) - def validate_type(self, value): + def validate_type(self, value: str) -> str: if not CommitFileChange.is_valid_type(value): raise serializers.ValidationError("Commit patch_set type %s is not supported." % value) return value diff --git a/src/sentry/api/serializers/rest_framework/dashboard.py b/src/sentry/api/serializers/rest_framework/dashboard.py index 71a35a31e73b..ee638e7ccea0 100644 --- a/src/sentry/api/serializers/rest_framework/dashboard.py +++ b/src/sentry/api/serializers/rest_framework/dashboard.py @@ -89,24 +89,24 @@ def is_aggregate(field: str) -> bool: return False -def get_next_query_order(widget_id): - max_order = DashboardWidgetQuery.objects.filter(widget_id=widget_id).aggregate(Max("order"))[ - "order__max" - ] +def get_next_query_order(widget_id: int) -> int: + max_order: int | None = DashboardWidgetQuery.objects.filter(widget_id=widget_id).aggregate( + Max("order") + )["order__max"] return max_order + 1 if max_order else 1 -def validate_id(self, value): +def validate_id(self: CamelSnakeSerializer[Dashboard], value: str) -> int: try: return int(value) except ValueError: raise serializers.ValidationError("Invalid ID format. Must be a numeric string") -def is_table_display_type(display_type): - return display_type == DashboardWidgetDisplayTypes.get_type_name( - DashboardWidgetDisplayTypes.TABLE +def is_table_display_type(display_type: object) -> bool: + return bool( + display_type == DashboardWidgetDisplayTypes.get_type_name(DashboardWidgetDisplayTypes.TABLE) ) diff --git a/src/sentry/api/serializers/rest_framework/origin.py b/src/sentry/api/serializers/rest_framework/origin.py index 3e2899ff4c6c..dc1052ad9787 100644 --- a/src/sentry/api/serializers/rest_framework/origin.py +++ b/src/sentry/api/serializers/rest_framework/origin.py @@ -1,20 +1,21 @@ +from typing import cast + from rest_framework import serializers from sentry.utils.http import parse_uri_match class OriginField(serializers.CharField): - # Special case origins that don't fit the normal regex pattern, but are valid WHITELIST_ORIGINS = "*" - def to_internal_value(self, data): + def to_internal_value(self, data: str) -> str: rv = super().to_internal_value(data) if not rv: - return + return cast(str, None) self.validate_origin(rv) return rv - def validate_origin(self, value): + def validate_origin(self, value: str) -> None: if value in self.WHITELIST_ORIGINS: return diff --git a/src/sentry/api/serializers/rest_framework/project_key.py b/src/sentry/api/serializers/rest_framework/project_key.py index a3941e0d0d5f..0302d7f62a19 100644 --- a/src/sentry/api/serializers/rest_framework/project_key.py +++ b/src/sentry/api/serializers/rest_framework/project_key.py @@ -1,3 +1,5 @@ +from collections.abc import Mapping + from rest_framework import serializers from sentry.api.fields.empty_integer import EmptyIntegerField @@ -52,16 +54,17 @@ class DynamicSdkLoaderOptionSerializer(serializers.Serializer): hasFeedback = serializers.BooleanField(required=False) hasLogsAndMetrics = serializers.BooleanField(required=False) - def to_internal_value(self, data): + def to_internal_value(self, data: Mapping[str, object]) -> dict[str, object]: # Drop any fields that are not specified as a `DynamicSdkLoaderOption`. allowed = {option.value for option in DynamicSdkLoaderOption} existing = set(data) - new_data = {} + new_data: dict[str, object] = {} for field_name in existing.intersection(allowed): new_data[field_name] = data[field_name] - return super().to_internal_value(new_data) + validated: dict[str, object] = super().to_internal_value(new_data) + return validated @sentry_schema_serializer( diff --git a/src/sentry/api/validators/auth.py b/src/sentry/api/validators/auth.py index e8aa7809140d..31ca3c567db6 100644 --- a/src/sentry/api/validators/auth.py +++ b/src/sentry/api/validators/auth.py @@ -24,13 +24,19 @@ class RegistrationRequest(TypedDict): timezone: NotRequired[str] -class AuthVerifyValidator(serializers.Serializer): +class AuthVerifyRequest(TypedDict, total=False): + password: str + challenge: str + response: str + + +class AuthVerifyValidator(serializers.Serializer[AuthVerifyRequest]): password = serializers.CharField(required=False, trim_whitespace=False) # For u2f challenge = serializers.CharField(required=False, trim_whitespace=False) response = serializers.CharField(required=False, trim_whitespace=False) - def validate(self, data): + def validate(self, data: AuthVerifyRequest) -> AuthVerifyRequest: if "password" in data: return data if "challenge" in data and "response" in data: diff --git a/src/sentry/api/validators/project.py b/src/sentry/api/validators/project.py index acd142976f6c..6115fe951ef5 100644 --- a/src/sentry/api/validators/project.py +++ b/src/sentry/api/validators/project.py @@ -1,9 +1,13 @@ +from collections.abc import Iterable + from rest_framework.exceptions import PermissionDenied from sentry.constants import ALL_ACCESS_PROJECTS -def validate_project_ids(raw_project_ids, associated_projects): +def validate_project_ids( + raw_project_ids: Iterable[int], associated_projects: Iterable[int] +) -> set[int]: raw_project_ids = set(raw_project_ids) # Don't need to check all projects or my projects diff --git a/src/sentry/apidocs/extensions.py b/src/sentry/apidocs/extensions.py index a3c67f9e2965..dfd8107e52d6 100644 --- a/src/sentry/apidocs/extensions.py +++ b/src/sentry/apidocs/extensions.py @@ -1,4 +1,4 @@ -from typing import Any, get_type_hints +from typing import Any, cast, get_type_hints from drf_spectacular.extensions import ( OpenApiAuthenticationExtension, @@ -102,8 +102,8 @@ class RestrictedJsonFieldExtension(OpenApiSerializerFieldExtension): target_class = "rest_framework.fields.JSONField" - def map_serializer_field(self, auto_schema, direction): - return build_basic_type(OpenApiTypes.OBJECT) + def map_serializer_field(self, auto_schema: AutoSchema, direction: Direction) -> dict[str, str]: + return cast(dict[str, str], build_basic_type(OpenApiTypes.OBJECT)) # TODO: extension to do default error codes on responses. diff --git a/src/sentry/attachments/default.py b/src/sentry/attachments/default.py index 13ef0f27ec9e..aca8221e2d20 100644 --- a/src/sentry/attachments/default.py +++ b/src/sentry/attachments/default.py @@ -4,5 +4,5 @@ class DefaultAttachmentCache(BaseAttachmentCache): - def __init__(self, **options): + def __init__(self, **options: object) -> None: super().__init__(default_cache, **options) diff --git a/src/sentry/audit_log/events.py b/src/sentry/audit_log/events.py index 2450b5a298d2..5a1a44a69b6f 100644 --- a/src/sentry/audit_log/events.py +++ b/src/sentry/audit_log/events.py @@ -192,7 +192,7 @@ def render(self, audit_log_entry: AuditLogEntry) -> str: return "edited project performance issue detector settings " + items_string -def render_project_action(audit_log_entry: AuditLogEntry, action: str): +def render_project_action(audit_log_entry: AuditLogEntry, action: str) -> str: # Most logs will just be name of the filter, but legacy browser changes can be bool, str, list, or sets filter_name = audit_log_entry.data["state"] slug = audit_log_entry.data.get("slug") diff --git a/src/sentry/backup/dependencies.py b/src/sentry/backup/dependencies.py index 9f15035970d8..2c567b4c5805 100644 --- a/src/sentry/backup/dependencies.py +++ b/src/sentry/backup/dependencies.py @@ -8,7 +8,7 @@ from django.db import models from django.db.models import Q, UniqueConstraint -from django.db.models.fields.related import ForeignKey, OneToOneField +from django.db.models.fields.related import OneToOneField from sentry.backup.helpers import EXCLUDED_APPS from sentry.backup.scopes import RelocationScope @@ -407,21 +407,24 @@ def dependencies() -> dict[NormalizedModelName, ModelRelations]: # Now add a dependency for any FK relation visible to Django. for field in model._meta.get_fields(): - is_nullable = getattr(field, "null", False) + is_nullable: bool = getattr(field, "null", False) + fk_field_name: str = field.name if field.name != "id" and getattr(field, "unique", False): - uniques.add(frozenset({field.name})) + uniques.add(frozenset({fk_field_name})) - rel_model = getattr(field.remote_field, "model", None) - if rel_model is not None and rel_model != model: + fk_rel_model: type[models.base.Model] | None = getattr( + field.remote_field, "model", None + ) + if fk_rel_model is not None and fk_rel_model != model: if isinstance(field, FlexibleForeignKey): foreign_keys[field.name] = ForeignField( - model=rel_model, + model=fk_rel_model, kind=ForeignFieldKind.FlexibleForeignKey, nullable=is_nullable, ) - elif isinstance(field, ForeignKey): - foreign_keys[field.name] = ForeignField( - model=rel_model, + elif field.many_to_one: + foreign_keys[fk_field_name] = ForeignField( + model=fk_rel_model, kind=ForeignFieldKind.DefaultForeignKey, nullable=is_nullable, ) @@ -442,22 +445,23 @@ def dependencies() -> dict[NormalizedModelName, ModelRelations]: if getattr(field, "unique", False): uniques.add(frozenset({field.name})) - rel_model = getattr(field.remote_field, "model", None) - if rel_model is not None and rel_model != model: + o2o_rel_model: type[models.base.Model] | None = getattr( + field.remote_field, "model", None + ) + if o2o_rel_model is not None and o2o_rel_model != model: if isinstance(field, OneToOneCascadeDeletes): foreign_keys[field.name] = ForeignField( - model=rel_model, + model=o2o_rel_model, kind=ForeignFieldKind.OneToOneCascadeDeletes, nullable=is_nullable, ) - elif isinstance(field, OneToOneField): + else: + # one_to_one_fields filters to OneToOneField instances. foreign_keys[field.name] = ForeignField( - model=rel_model, + model=o2o_rel_model, kind=ForeignFieldKind.DefaultOneToOneField, nullable=is_nullable, ) - else: - raise RuntimeError("Unknown one to kind") # Use some heuristics to grab numeric-only unlinked dependencies. simple_integer_fields = [ diff --git a/src/sentry/core/endpoints/organization_avatar.py b/src/sentry/core/endpoints/organization_avatar.py index c9eb2f2797f1..92839df8835a 100644 --- a/src/sentry/core/endpoints/organization_avatar.py +++ b/src/sentry/core/endpoints/organization_avatar.py @@ -3,10 +3,13 @@ from sentry.api.bases.avatar import AvatarMixin from sentry.api.bases.organization import OrganizationEndpoint from sentry.models.avatars.organization_avatar import OrganizationAvatar +from sentry.models.organization import Organization @cell_silo_endpoint -class OrganizationAvatarEndpoint(AvatarMixin[OrganizationAvatar], OrganizationEndpoint): +class OrganizationAvatarEndpoint( + AvatarMixin[OrganizationAvatar, Organization], OrganizationEndpoint +): publish_status = { "GET": ApiPublishStatus.PRIVATE, "PUT": ApiPublishStatus.PRIVATE, @@ -14,6 +17,6 @@ class OrganizationAvatarEndpoint(AvatarMixin[OrganizationAvatar], OrganizationEn object_type = "organization" model = OrganizationAvatar - def get_avatar_filename(self, obj): + def get_avatar_filename(self, obj: Organization) -> str: # for consistency with organization details endpoint return f"{obj.slug}.png" diff --git a/src/sentry/core/endpoints/organization_details.py b/src/sentry/core/endpoints/organization_details.py index 6ed059cb09b3..c8f6f731d471 100644 --- a/src/sentry/core/endpoints/organization_details.py +++ b/src/sentry/core/endpoints/organization_details.py @@ -377,12 +377,12 @@ class OrganizationSerializer(BaseOrganizationSerializer): help_text="List of user IDs that have access to replay data. Only modifiable by owners and managers.", ) - def _has_sso_enabled(self): + def _has_sso_enabled(self) -> bool: org = self.context["organization"] org_auth_provider = auth_service.get_auth_provider(organization_id=org.id) return org_auth_provider is not None - def validate_relayPiiConfig(self, value): + def validate_relayPiiConfig(self, value: str | None) -> str | None: organization = self.context["organization"] return validate_pii_config_update(organization, value) @@ -411,33 +411,33 @@ def validate_defaultAutomatedRunStoppingPoint(self, value: str) -> str: raise serializers.ValidationError(f'"{value}" is not a valid choice.') return value - def validate_sensitiveFields(self, value): + def validate_sensitiveFields(self, value: list[str]) -> list[str]: if value and not all(value): raise serializers.ValidationError("Empty values are not allowed.") if sum(map(len, value)) > MAX_SENSITIVE_FIELD_CHARS: raise serializers.ValidationError("List of sensitive fields is too long.") return value - def validate_safeFields(self, value): + def validate_safeFields(self, value: list[str]) -> list[str]: if value and not all(value): raise serializers.ValidationError("Empty values are not allowed.") return validate_pii_selectors(value) - def validate_attachmentsRole(self, value): + def validate_attachmentsRole(self, value: str) -> str: try: roles.get(value) except KeyError: raise serializers.ValidationError("Invalid role") return value - def validate_debugFilesRole(self, value): + def validate_debugFilesRole(self, value: str) -> str: try: roles.get(value) except KeyError: raise serializers.ValidationError("Invalid role") return value - def validate_require2FA(self, value): + def validate_require2FA(self, value: bool) -> bool: user = self.context["user"] has_2fa = user.has_2fa() if value and not has_2fa: @@ -447,7 +447,7 @@ def validate_require2FA(self, value): raise serializers.ValidationError(ERR_SSO_ENABLED) return value - def validate_trustedRelays(self, value): + def validate_trustedRelays(self, value: list[dict[str, object]]) -> list[dict[str, object]]: # make sure we don't have multiple instances of one public key public_keys = set() if value is not None: @@ -487,7 +487,7 @@ def validate_relayDsnEndpoint(self, value: str | None) -> str | None: return value.rstrip("/") - def validate_enabledConsolePlatforms(self, value): + def validate_enabledConsolePlatforms(self, value: list[str] | None) -> list[str] | None: request = self.context["request"] if not is_active_staff(request): @@ -499,7 +499,7 @@ def validate_enabledConsolePlatforms(self, value): return value - def validate_consoleSdkInviteQuota(self, value): + def validate_consoleSdkInviteQuota(self, value: int) -> int: request = self.context["request"] if not is_active_staff(request): @@ -509,7 +509,7 @@ def validate_consoleSdkInviteQuota(self, value): return value - def validate_targetSampleRate(self, value): + def validate_targetSampleRate(self, value: float) -> float: organization = self.context["organization"] request = self.context["request"] has_dynamic_sampling_custom = has_custom_dynamic_sampling(organization, actor=request.user) @@ -520,7 +520,7 @@ def validate_targetSampleRate(self, value): return value - def validate_samplingMode(self, value): + def validate_samplingMode(self, value: str) -> str: organization = self.context["organization"] request = self.context["request"] has_dynamic_sampling_custom = has_custom_dynamic_sampling(organization, actor=request.user) @@ -541,15 +541,15 @@ def validate_samplingMode(self, value): return value - def validate_hasGranularReplayPermissions(self, value): + def validate_hasGranularReplayPermissions(self, value: bool) -> bool: self._validate_granular_replay_permissions() return value - def validate_replayAccessMembers(self, value): + def validate_replayAccessMembers(self, value: list[int] | None) -> list[int] | None: self._validate_granular_replay_permissions() return value - def _validate_granular_replay_permissions(self): + def _validate_granular_replay_permissions(self) -> None: request = self.context["request"] if not request.access.has_scope("org:write"): @@ -557,7 +557,7 @@ def _validate_granular_replay_permissions(self): "You do not have permission to modify granular replay permissions." ) - def validate(self, attrs): + def validate(self, attrs: dict[str, object]) -> dict[str, object]: attrs = super().validate(attrs) if attrs.get("avatarType") == "upload": has_existing_file = OrganizationAvatar.objects.filter( @@ -586,7 +586,12 @@ def validate(self, attrs): return attrs - def save_trusted_relays(self, incoming, changed_data, organization): + def save_trusted_relays( + self, + incoming: list[dict[str, object]], + changed_data: dict[str, str], + organization: Organization, + ) -> list[dict[str, object]]: timestamp_now = datetime.now(timezone.utc).isoformat() option_key = "sentry:trusted-relays" try: @@ -604,7 +609,7 @@ def save_trusted_relays(self, incoming, changed_data, organization): modified = False for option in incoming: public_key = option.get("public_key") - existing_info = key_dict.get(public_key, {}) + existing_info: dict[str, object] = key_dict.get(public_key, {}) option["created"] = existing_info.get("created", timestamp_now) option["last_modified"] = existing_info.get("last_modified") @@ -638,7 +643,7 @@ def save_trusted_relays(self, incoming, changed_data, organization): return incoming - def save(self, **kwargs): + def save(self, **kwargs: object) -> tuple[Organization, dict[str, str]]: org = self.context["organization"] changed_data = {} if not hasattr(org, "__data"): @@ -809,7 +814,7 @@ class OwnerOrganizationSerializer(OrganizationSerializer): cancelDeletion = serializers.BooleanField(required=False) idempotencyKey = serializers.CharField(max_length=IDEMPOTENCY_KEY_LENGTH, required=False) - def save(self, *args, **kwargs): + def save(self, *args: object, **kwargs: object) -> tuple[Organization, dict[str, str]]: org = self.context["organization"] update_tracked_data(org) data = self.validated_data @@ -827,7 +832,7 @@ def save(self, *args, **kwargs): def post_org_pending_deletion( *, request: Request, org_delete_response: RpcOrganizationDeleteResponse -): +) -> None: if org_delete_response.response_state == RpcOrganizationDeleteState.PENDING_DELETION: updated_organization = org_delete_response.updated_organization assert updated_organization @@ -865,8 +870,11 @@ def post_org_pending_deletion( def create_console_platform_audit_log( - request, organization, previously_enabled_platforms, currently_requested_platforms -): + request: Request, + organization: Organization, + previously_enabled_platforms: list[str] | None, + currently_requested_platforms: list[str] | None, +) -> None: """Create a single audit log entry for console platform changes.""" prev = set(previously_enabled_platforms or []) curr = set(currently_requested_platforms or []) @@ -1295,7 +1303,7 @@ def put( return self.respond(context) return self.respond(as_validation_errors(serializer), status=status.HTTP_400_BAD_REQUEST) - def handle_delete(self, request: Request, organization: Organization): + def handle_delete(self, request: Request, organization: Organization) -> Response: """ This method exists as a way for getsentry to override this endpoint with less duplication. """ @@ -1335,7 +1343,7 @@ def handle_delete(self, request: Request, organization: Organization): return self.respond(context, status=202) @sudo_required - def delete(self, request: Request, organization) -> Response: + def delete(self, request: Request, organization: Organization) -> Response: """ Delete an Organization `````````````````````` @@ -1356,9 +1364,9 @@ def delete(self, request: Request, organization) -> Response: return self.handle_delete(request, organization) -def flag_has_changed(org, flag_name): +def flag_has_changed(org: Organization, flag_name: str) -> bool: "Returns ``True`` if ``flag`` has changed since initialization." - return getattr(old_value(org, "flags"), flag_name, None) != getattr(org.flags, flag_name) + return bool(getattr(old_value(org, "flags"), flag_name, None) != getattr(org.flags, flag_name)) def update_tracked_data(model): @@ -1391,7 +1399,7 @@ class DeleteConfirmationArgs(TypedDict): countdown: int -def send_delete_confirmation(delete_confirmation_args: DeleteConfirmationArgs): +def send_delete_confirmation(delete_confirmation_args: DeleteConfirmationArgs) -> None: from sentry.utils.email import MessageBuilder organization = delete_confirmation_args["organization"] diff --git a/src/sentry/core/endpoints/organization_member_requests_invite_details.py b/src/sentry/core/endpoints/organization_member_requests_invite_details.py index a14a22ab6ae4..d194ae3e12f4 100644 --- a/src/sentry/core/endpoints/organization_member_requests_invite_details.py +++ b/src/sentry/core/endpoints/organization_member_requests_invite_details.py @@ -25,6 +25,10 @@ from sentry.utils.audit import get_api_key_for_audit_log +class _AuthenticatedRequest(Request): + user: User + + class ApproveInviteRequestSerializer(serializers.Serializer[dict[str, Any]]): approve = serializers.BooleanField(required=True, write_only=True) @@ -87,7 +91,7 @@ def get( def put( self, - request: Request, + request: _AuthenticatedRequest, organization: Organization, member: OrganizationMember, ) -> Response: @@ -180,7 +184,7 @@ def put( def delete( self, - request: Request, + request: _AuthenticatedRequest, organization: Organization, member: OrganizationMember, ) -> Response: diff --git a/src/sentry/core/endpoints/project_transfer.py b/src/sentry/core/endpoints/project_transfer.py index f221d8b71114..90b1733bfa43 100644 --- a/src/sentry/core/endpoints/project_transfer.py +++ b/src/sentry/core/endpoints/project_transfer.py @@ -1,5 +1,5 @@ import logging -from typing import Any +from typing import Any, cast from urllib.parse import urlencode from uuid import uuid4 @@ -22,8 +22,10 @@ RESPONSE_UNAUTHORIZED, ) from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.response_types import DetailResponse from sentry.models.options.project_option import ProjectOption from sentry.models.organizationmember import OrganizationMember +from sentry.models.project import Project from sentry.ratelimits.config import RateLimitConfig from sentry.types.ratelimit import RateLimit, RateLimitCategory from sentry.utils.email import MessageBuilder @@ -83,7 +85,9 @@ def rate_limits(*args: Any, **kwargs: Any) -> RateLimitConfig: }, ) @sudo_required - def post(self, request: Request, project) -> Response: + def post( + self, request: Request, project: Project + ) -> Response[DetailResponse] | Response[str] | Response[None]: """ Schedule a project for transfer to a new organization. @@ -116,7 +120,7 @@ def post(self, request: Request, project) -> Response: status=status.HTTP_400_BAD_REQUEST, ) - unique_user_ids: set[int] = {owner.user_id for owner in owners} + unique_user_ids: set[int] = {cast(int, owner.user_id) for owner in owners} if len(unique_user_ids) == 0: return Response( diff --git a/src/sentry/core/endpoints/team_avatar.py b/src/sentry/core/endpoints/team_avatar.py index 3a1d2e3ebcf7..42fc625e4b95 100644 --- a/src/sentry/core/endpoints/team_avatar.py +++ b/src/sentry/core/endpoints/team_avatar.py @@ -6,6 +6,7 @@ from sentry.api.bases.avatar import AvatarMixin, AvatarSerializer from sentry.api.bases.team import TeamEndpoint from sentry.models.avatars.team_avatar import TeamAvatar +from sentry.models.team import Team class TeamAvatarSerializer(AvatarSerializer): @@ -15,7 +16,7 @@ class TeamAvatarSerializer(AvatarSerializer): @cell_silo_endpoint -class TeamAvatarEndpoint(AvatarMixin[TeamAvatar], TeamEndpoint): +class TeamAvatarEndpoint(AvatarMixin[TeamAvatar, Team], TeamEndpoint): owner = ApiOwner.FOUNDATIONS publish_status = { "GET": ApiPublishStatus.PRIVATE, @@ -25,5 +26,5 @@ class TeamAvatarEndpoint(AvatarMixin[TeamAvatar], TeamEndpoint): model = TeamAvatar serializer_cls = TeamAvatarSerializer - def get_avatar_filename(self, obj): + def get_avatar_filename(self, obj: Team) -> str: return f"{obj.slug}.png" diff --git a/src/sentry/db/models/fields/bounded.py b/src/sentry/db/models/fields/bounded.py index c68991983491..7feb1cbbe71d 100644 --- a/src/sentry/db/models/fields/bounded.py +++ b/src/sentry/db/models/fields/bounded.py @@ -1,6 +1,7 @@ from __future__ import annotations from django.db import models +from django.db.models.expressions import Combinable from django.utils.translation import gettext_lazy as _ __all__ = ( @@ -106,6 +107,9 @@ def get_prep_value(self, value: int) -> int: class BoundedBigAutoField(models.BigAutoField): + _pyi_private_set_type: float | int | str | Combinable + _pyi_private_get_type: int + description = _("Big Integer") MAX_VALUE = I64_MAX diff --git a/src/sentry/db/models/fields/onetoone.py b/src/sentry/db/models/fields/onetoone.py index 1451f3cbd858..4a757467558e 100644 --- a/src/sentry/db/models/fields/onetoone.py +++ b/src/sentry/db/models/fields/onetoone.py @@ -3,10 +3,12 @@ from django.db import models from django.db.models import OneToOneField +from sentry.db.models.fields.types import FieldGetType, FieldSetType + __all__ = ("OneToOneCascadeDeletes",) -class OneToOneCascadeDeletes(OneToOneField): - def __init__(self, *args: Any, **kwargs: Any): +class OneToOneCascadeDeletes(OneToOneField[FieldSetType, FieldGetType]): + def __init__(self, *args: Any, **kwargs: Any) -> None: kwargs.setdefault("on_delete", models.CASCADE) super().__init__(*args, **kwargs) diff --git a/src/sentry/debug_files/artifact_bundles.py b/src/sentry/debug_files/artifact_bundles.py index 3d49fe45f267..5560720f3da9 100644 --- a/src/sentry/debug_files/artifact_bundles.py +++ b/src/sentry/debug_files/artifact_bundles.py @@ -2,7 +2,7 @@ import logging from datetime import datetime, timedelta -from typing import Any +from typing import Any, cast import orjson import sentry_sdk @@ -141,7 +141,7 @@ def is_named_after_own_debug_id(url: str, info: dict[str, Any]) -> bool: URL of every frame that has one, which the lookup resolves before it uses the URL index. """ headers = ArtifactBundleArchive.normalize_headers(info.get("headers", {})) - debug_id = ArtifactBundleArchive.normalize_debug_id(headers.get("debug-id")) + debug_id = ArtifactBundleArchive.normalize_debug_id(cast(str | None, headers.get("debug-id"))) if debug_id is None: return False file_name = url.rsplit("/", 1)[-1].lower() diff --git a/src/sentry/discover/arithmetic.py b/src/sentry/discover/arithmetic.py index 1f3285ffb803..ddfd2b002308 100644 --- a/src/sentry/discover/arithmetic.py +++ b/src/sentry/discover/arithmetic.py @@ -87,7 +87,7 @@ class ParsedEquation: contains_functions: bool -def flatten(remaining): +def flatten(remaining: list[Operation]) -> Operation: """Take all the remaining terms and reduce them to a single tree""" term = remaining.pop(0) while remaining: @@ -278,7 +278,7 @@ def visit_factor(self, _, children): remaining_muls[0].lhs = primary return flatten(remaining_muls) - def visited_operator(self): + def visited_operator(self) -> None: """We visited an operator, increment the count and error if we exceed max""" self.operators += 1 if self.operators > self.max_operators: @@ -486,7 +486,7 @@ def strip_equation(field: str) -> str: return field[len(EQUATION_PREFIX) :] -def categorize_columns(columns) -> tuple[list[str], list[str]]: +def categorize_columns(columns: Iterable[str]) -> tuple[list[str], list[str]]: """equations have a prefix so that they can be easily included alongside our existing fields""" equations = [] fields = [] diff --git a/src/sentry/exceptions.py b/src/sentry/exceptions.py index 18eaaec36478..1034e84b4efc 100644 --- a/src/sentry/exceptions.py +++ b/src/sentry/exceptions.py @@ -17,7 +17,7 @@ class InvalidRequest(Exception): class InvalidOrigin(InvalidRequest): - def __init__(self, origin): + def __init__(self, origin: str) -> None: self.origin = origin def __str__(self) -> str: @@ -45,7 +45,7 @@ class PluginIdentityRequired(PluginError): class InvalidIdentity(Exception): - def __init__(self, message="", identity=None): + def __init__(self, message: str = "", identity: object | None = None) -> None: super().__init__(message) self.identity = identity diff --git a/src/sentry/explore/endpoints/explore_saved_queries.py b/src/sentry/explore/endpoints/explore_saved_queries.py index 5d775f1f3892..b2f7adba55a5 100644 --- a/src/sentry/explore/endpoints/explore_saved_queries.py +++ b/src/sentry/explore/endpoints/explore_saved_queries.py @@ -44,6 +44,7 @@ from sentry.locks import locks from sentry.models.organization import Organization from sentry.search.utils import tokenize_query +from sentry.users.models.user import User from sentry.utils.locking import UnableToAcquireLock PREBUILT_SAVED_QUERIES = [ @@ -194,7 +195,7 @@ ] -def sync_prebuilt_queries(organization): +def sync_prebuilt_queries(organization: Organization) -> None: """ Queries the database to check if prebuilt queries have an ExploreSavedQuery record and creates them if they don't, updates them if they're outdated, or deletes them if they @@ -269,7 +270,7 @@ def sync_prebuilt_queries(organization): ).delete() -def sync_prebuilt_queries_starred(organization, user): +def sync_prebuilt_queries_starred(organization: Organization, user: User) -> None: """ Queries the database to check if prebuilt queries have an ExploreSavedQueryStarred record for the user_id, and creates them if they don't. This ensures that prebuilt queries are starred by default for all users. @@ -322,7 +323,7 @@ class ExploreSavedQueriesEndpoint(OrganizationEndpoint): owner = ApiOwner.EXPLORE permission_classes = (ExploreSavedQueryPermission,) - def has_feature(self, organization, request): + def has_feature(self, organization: Organization, request: Request) -> bool: return features.has( "organizations:visibility-explore-view", organization, actor=request.user ) @@ -507,7 +508,7 @@ def get( queryset = queryset.order_by(*order_by) - def data_fn(offset, limit): + def data_fn(offset: int, limit: int) -> list[ExploreSavedQuery]: return list(queryset[offset : offset + limit]) return self.paginate( diff --git a/src/sentry/explore/endpoints/explore_saved_query_detail.py b/src/sentry/explore/endpoints/explore_saved_query_detail.py index 2e499d33126d..00571a046124 100644 --- a/src/sentry/explore/endpoints/explore_saved_query_detail.py +++ b/src/sentry/explore/endpoints/explore_saved_query_detail.py @@ -59,7 +59,7 @@ class ExploreSavedQueryDetailEndpoint(ExploreSavedQueryBase): "PUT": ApiPublishStatus.EXPERIMENTAL, } - def has_feature(self, organization, request): + def has_feature(self, organization: Organization, request: Request) -> bool: return features.has( "organizations:visibility-explore-view", organization, actor=request.user ) @@ -182,12 +182,14 @@ class ExploreSavedQueryVisitEndpoint(ExploreSavedQueryBase): "POST": ApiPublishStatus.PRIVATE, } - def has_feature(self, organization, request): + def has_feature(self, organization: Organization, request: Request) -> bool: return features.has( "organizations:visibility-explore-view", organization, actor=request.user ) - def post(self, request: Request, organization, query) -> Response: + def post( + self, request: Request, organization: Organization, query: ExploreSavedQuery + ) -> Response: """ Update last_visited and increment visits counter """ diff --git a/src/sentry/explore/endpoints/explore_saved_query_starred.py b/src/sentry/explore/endpoints/explore_saved_query_starred.py index c4ca05ce727a..746f511ad916 100644 --- a/src/sentry/explore/endpoints/explore_saved_query_starred.py +++ b/src/sentry/explore/endpoints/explore_saved_query_starred.py @@ -1,3 +1,5 @@ +from typing import NotRequired, TypedDict + from drf_spectacular.utils import extend_schema from rest_framework import serializers, status from rest_framework.request import Request @@ -20,7 +22,12 @@ from sentry.models.organization import Organization -class StarQuerySerializer(serializers.Serializer): +class StarQueryData(TypedDict): + starred: bool + position: NotRequired[int] + + +class StarQuerySerializer(serializers.Serializer[object, StarQueryData]): starred = serializers.BooleanField( required=True, help_text="Whether the query should be starred for the current member." ) @@ -29,7 +36,7 @@ class StarQuerySerializer(serializers.Serializer): help_text="Where the query sits in the member's starred list. Only allowed when starring.", ) - def validate(self, data): + def validate(self, data: StarQueryData) -> StarQueryData: if not data["starred"] and "position" in data: raise serializers.ValidationError("Position is only allowed when starring a query.") return data @@ -50,7 +57,7 @@ class ExploreSavedQueryStarredEndpoint(OrganizationEndpoint): owner = ApiOwner.EXPLORE permission_classes = (MemberPermission,) - def has_feature(self, organization, request): + def has_feature(self, organization: Organization, request: Request) -> bool: return features.has( "organizations:visibility-explore-view", organization, actor=request.user ) diff --git a/src/sentry/explore/endpoints/serializers.py b/src/sentry/explore/endpoints/serializers.py index fbe4e37d6971..7cfd23acb0e0 100644 --- a/src/sentry/explore/endpoints/serializers.py +++ b/src/sentry/explore/endpoints/serializers.py @@ -1,5 +1,7 @@ import logging -from datetime import timedelta +from collections.abc import Mapping, Sequence +from datetime import datetime, timedelta +from typing import Literal, NotRequired, TypedDict, cast from rest_framework import serializers from rest_framework.exceptions import ParseError, ValidationError @@ -19,6 +21,62 @@ logger = logging.getLogger(__name__) +class AggregateFieldData(TypedDict, total=False): + chartType: int + yAxes: list[str] + groupBy: str + + +class VisualizeData(TypedDict): + yAxes: list[str] + chartType: NotRequired[int] + + +class CrossEventData(TypedDict): + query: str + type: Literal["spans", "logs", "metrics"] + metric: NotRequired[dict[str, str | None] | None] + + +class QueryDataOptional(TypedDict, total=False): + fields: list[str] | None + orderby: str | None + groupby: list[str] | None + query: str | None + visualize: list[VisualizeData] | None + aggregateField: list[AggregateFieldData] | None + aggregateOrderby: str | None + metric: dict[str, str | None] | None + caseInsensitive: bool + + +class QueryData(QueryDataOptional): + mode: Literal["samples", "aggregate"] + + +class ExploreSavedQueryInput(TypedDict): + name: str + projects: list[int] | set[int] + dataset: Literal["spans", "logs", "segment_spans", "metrics", "replays", "ai_conversations"] + query: list[QueryData] + start: NotRequired[datetime | None] + end: NotRequired[datetime | None] + range: NotRequired[str | None] + statsPeriod: NotRequired[str | None] + environment: NotRequired[list[str] | None] + interval: NotRequired[str | None] + crossEvents: NotRequired[list[CrossEventData] | None] + agent: NotRequired[list[str] | None] + errorsOnly: NotRequired[bool | None] + + +class ExploreSavedQueryValidatedData(TypedDict): + name: str + project_ids: list[int] | set[int] + query: dict[str, object] + dataset: int + + class VisualizeSerializer(serializers.Serializer): chartType = serializers.IntegerField(required=False) yAxes = serializers.ListField(child=serializers.CharField()) @@ -42,7 +100,7 @@ class AggregateFieldSerializer(serializers.Serializer): # group bys groupBy = serializers.CharField(required=False) - def validate(self, data): + def validate(self, data: AggregateFieldData) -> AggregateFieldData: visualize_serializer = VisualizeSerializer(data=data) group_by_serializer = GroupBySerializer(data=data) @@ -176,7 +234,7 @@ class CrossEventSerializer(serializers.Serializer): help_text="The metric configuration (only used when type is metrics).", ) - def validate(self, data): + def validate(self, data: CrossEventData) -> CrossEventData: if data.get("type") == "metrics" and not data.get("metric"): raise serializers.ValidationError( "Metric field is required for metrics cross-event entries" @@ -188,7 +246,7 @@ def validate(self, data): return data -class ExploreSavedQuerySerializer(serializers.Serializer): +class ExploreSavedQuerySerializer(serializers.Serializer[object, ExploreSavedQueryValidatedData]): name = serializers.CharField( required=True, max_length=255, help_text="The user-defined saved query name." ) @@ -252,19 +310,23 @@ class ExploreSavedQuerySerializer(serializers.Serializer): help_text="The Explore query definitions to save.", ) - def validate_projects(self, projects): + def validate_projects(self, projects: list[int]) -> set[int]: from sentry.api.validators import validate_project_ids return validate_project_ids(projects, self.context["params"]["project_id"]) # Avoid including any side-effecting logic here, since this logic is also used when generating prebuilt queries on first read - def validate(self, data): - transformation_pipelines = [] - for query in data["query"]: + def validate(self, data: ExploreSavedQueryInput) -> ExploreSavedQueryValidatedData: + transformation_pipelines: list[Sequence[str]] = [] + for query_data in data["query"]: for field in ("visualize", "aggregateField"): - for visualization in query.get(field) or []: + for visualization in cast( + Sequence[Mapping[str, object]], query_data.get(field) or [] + ): if "transformations" in visualization: - transformation_pipelines.append(visualization["transformations"]) + transformation_pipelines.append( + cast(Sequence[str], visualization["transformations"]) + ) if transformation_pipelines and not features.has( "organizations:explore-interpolation-and-smoothing", self.context["organization"], @@ -276,7 +338,7 @@ def validate(self, data): with handle_query_errors(): parse_transformations(transformations) - query = {} + query: dict[str, object] = {} query_keys = [ "environment", "statsPeriod", @@ -303,11 +365,11 @@ def validate(self, data): for key in query_keys: if data.get(key) is not None: - value = data[key] + value = data.get(key) if key in ("start", "end"): - value = value.isoformat() + value = cast(datetime, value).isoformat() if key == "crossEvents": - value = [dict(ce) for ce in value] + value = [dict(ce) for ce in cast(list[CrossEventData], value)] query[key] = value if query.get("crossEvents"): @@ -319,7 +381,8 @@ def validate(self, data): raise serializers.ValidationError("Cross event queries are limited to 7 days.") if "query" in data: - query["query"] = [] + inner_queries: list[dict[str, object]] = [] + query["query"] = inner_queries for q in data["query"]: if "metric" in q and data["dataset"] != "metrics": raise serializers.ValidationError( @@ -337,11 +400,11 @@ def validate(self, data): raise serializers.ValidationError( "Metric field is required for non-equation queries on the metrics dataset" ) - inner_query = {} + inner_query: dict[str, object] = {} for key in inner_query_keys: if key in q: - inner_query[key] = q[key] - query["query"].append(inner_query) + inner_query[key] = q.get(key) + inner_queries.append(inner_query) if data["projects"] == ALL_ACCESS_PROJECTS: data["projects"] = [] @@ -349,7 +412,7 @@ def validate(self, data): if "query" in query: if "interval" in query: - interval = parse_stats_period(query["interval"]) + interval = parse_stats_period(cast(str, query["interval"])) if interval is None: raise serializers.ValidationError("Interval could not be parsed") date_range = self.context["params"]["end"] - self.context["params"]["start"] diff --git a/src/sentry/identity/datadog/provider.py b/src/sentry/identity/datadog/provider.py index 085392ac56bb..0eab4b7e120f 100644 --- a/src/sentry/identity/datadog/provider.py +++ b/src/sentry/identity/datadog/provider.py @@ -213,7 +213,7 @@ def dispatch(self, request: HttpRequest, pipeline: IdentityPipeline) -> HttpResp return super().dispatch(request, pipeline) - def get_authorize_params(self, state: str, redirect_uri: str) -> dict[str, str | None]: + def get_authorize_params(self, state: str, redirect_uri: str) -> dict[str, str | int | None]: params = super().get_authorize_params(state, redirect_uri) params["resource"] = self.resource @@ -245,7 +245,7 @@ def exchange_token( "error_description": "Could not authenticate due to missing pipeline state", } - def get_token_params(self, code: str, redirect_uri: str) -> dict[str, str | None]: + def get_token_params(self, code: str, redirect_uri: str) -> dict[str, str | int | None]: return { "grant_type": "authorization_code", "code": code, diff --git a/src/sentry/identity/gcp/provider.py b/src/sentry/identity/gcp/provider.py index 9a9efe4c115c..1ebf40a25a13 100644 --- a/src/sentry/identity/gcp/provider.py +++ b/src/sentry/identity/gcp/provider.py @@ -28,7 +28,7 @@ class GCPOAuth2LoginView(OAuth2LoginView): """OAuth2 login for the GCP MCP with offline access for refresh tokens.""" - def get_authorize_params(self, state: str, redirect_uri: str) -> dict[str, str | None]: + def get_authorize_params(self, state: str, redirect_uri: str) -> dict[str, str | int | None]: params = super().get_authorize_params(state, redirect_uri) params["access_type"] = "offline" # Required for Google to return a refresh token params["prompt"] = ( diff --git a/src/sentry/identity/oauth2.py b/src/sentry/identity/oauth2.py index f68c68aadd9b..6f577bf2e828 100644 --- a/src/sentry/identity/oauth2.py +++ b/src/sentry/identity/oauth2.py @@ -129,7 +129,7 @@ def get_oauth_client_secret(self): def get_oauth_scopes(self): return self.config.get("oauth_scopes", self.oauth_scopes) - def get_refresh_token_headers(self): + def get_refresh_token_headers(self) -> dict[str, str] | None: return None def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]: @@ -229,7 +229,7 @@ def refresh_identity(self, identity: Identity | RpcIdentity, **kwargs: Any) -> N identity_service.update_data(identity_id=identity.id, data=identity.data) -def record_event(event: IntegrationPipelineViewType, provider: str): +def record_event(event: IntegrationPipelineViewType, provider: str) -> IntegrationPipelineViewEvent: from sentry.identity import default_manager as identity_manager from sentry.integrations.base import IntegrationDomain @@ -373,7 +373,7 @@ def _exchange_token(self, code: str) -> dict[str, Any]: class OAuth2LoginView: authorize_url: str | None = None - client_id: str | None = None + client_id: str | int | None = None scope = "" def __init__(self, authorize_url=None, client_id=None, scope=None, *args, **kwargs): @@ -385,13 +385,13 @@ def __init__(self, authorize_url=None, client_id=None, scope=None, *args, **kwar if scope is not None: self.scope = scope - def get_scope(self): + def get_scope(self) -> str: return self.scope - def get_authorize_url(self): + def get_authorize_url(self) -> str | None: return self.authorize_url - def get_authorize_params(self, state, redirect_uri): + def get_authorize_params(self, state: str, redirect_uri: str) -> dict[str, str | int | None]: return { "client_id": self.client_id, "response_type": "code", @@ -423,7 +423,7 @@ def dispatch(self, request: HttpRequest, pipeline: IdentityPipeline) -> HttpResp class OAuth2CallbackView: access_token_url: str | None = None - client_id: str | None = None + client_id: str | int | None = None client_secret: str | None = None def __init__(self, access_token_url=None, client_id=None, client_secret=None, *args, **kwargs): @@ -435,7 +435,7 @@ def __init__(self, access_token_url=None, client_id=None, client_secret=None, *a if client_secret is not None: self.client_secret = client_secret - def get_token_params(self, code: str, redirect_uri: str) -> dict[str, str | None]: + def get_token_params(self, code: str, redirect_uri: str) -> dict[str, str | int | None]: return { "grant_type": "authorization_code", "code": code, diff --git a/src/sentry/insights/endpoints/starred_segments.py b/src/sentry/insights/endpoints/starred_segments.py index 2f464d170bb9..c0b54ae50df1 100644 --- a/src/sentry/insights/endpoints/starred_segments.py +++ b/src/sentry/insights/endpoints/starred_segments.py @@ -1,3 +1,5 @@ +from typing import TypedDict + from django.db import router from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import serializers, status @@ -23,7 +25,12 @@ from sentry.utils.db import atomic_transaction -class StarSegmentSerializer(serializers.Serializer): +class StarSegmentValidatedData(TypedDict): + service_span: str + project_id: int + + +class StarSegmentSerializer(serializers.Serializer[object, StarSegmentValidatedData]): service_span = serializers.CharField( required=True, help_text="The name of the service span to star or unstar.", diff --git a/src/sentry/integrations/api/endpoints/doc_integration_avatar.py b/src/sentry/integrations/api/endpoints/doc_integration_avatar.py index bd1eb51ca408..d350bc169067 100644 --- a/src/sentry/integrations/api/endpoints/doc_integration_avatar.py +++ b/src/sentry/integrations/api/endpoints/doc_integration_avatar.py @@ -6,11 +6,14 @@ from sentry.integrations.api.serializers.rest_framework.doc_integration import ( DocIntegrationAvatarSerializer, ) +from sentry.integrations.models.doc_integration import DocIntegration from sentry.integrations.models.doc_integration_avatar import DocIntegrationAvatar @control_silo_endpoint -class DocIntegrationAvatarEndpoint(AvatarMixin[DocIntegrationAvatar], DocIntegrationBaseEndpoint): +class DocIntegrationAvatarEndpoint( + AvatarMixin[DocIntegrationAvatar, DocIntegration], DocIntegrationBaseEndpoint +): owner = ApiOwner.INTEGRATION_PLATFORM publish_status = { "GET": ApiPublishStatus.PRIVATE, @@ -20,5 +23,5 @@ class DocIntegrationAvatarEndpoint(AvatarMixin[DocIntegrationAvatar], DocIntegra model = DocIntegrationAvatar serializer_cls = DocIntegrationAvatarSerializer - def get_avatar_filename(self, obj) -> str: + def get_avatar_filename(self, obj: DocIntegration) -> str: return f"{obj.slug}.png" diff --git a/src/sentry/integrations/api/serializers/rest_framework/data_forwarder.py b/src/sentry/integrations/api/serializers/rest_framework/data_forwarder.py index 4e84a88ba674..d299c1c4685a 100644 --- a/src/sentry/integrations/api/serializers/rest_framework/data_forwarder.py +++ b/src/sentry/integrations/api/serializers/rest_framework/data_forwarder.py @@ -1,6 +1,6 @@ import re from collections.abc import Mapping, MutableMapping -from typing import Any, TypedDict +from typing import Any from django.db import router, transaction from rest_framework import serializers @@ -12,27 +12,6 @@ from sentry.integrations.types import DataForwarderProviderSlug from sentry.models.project import Project - -class SQSConfig(TypedDict, total=False): - queue_url: str - region: str - access_key: str - secret_key: str - message_group_id: str | None - s3_bucket: str | None - - -class SegmentConfig(TypedDict, total=False): - write_key: str - - -class SplunkConfig(TypedDict, total=False): - instance_url: str - index: str - source: str - token: str - - SQS_REQUIRED_KEYS = ["queue_url", "region", "access_key", "secret_key"] SEGMENT_REQUIRED_KEYS = ["write_key"] SPLUNK_REQUIRED_KEYS = ["instance_url", "index", "source", "token"] @@ -70,7 +49,7 @@ class DataForwarderSerializer(Serializer): help_text="The IDs of the projects connected to the data forwarder. Missing project IDs will be unenrolled if previously enrolled.", ) - def validate_config(self, config) -> SQSConfig | SegmentConfig | SplunkConfig: + def validate_config(self, config: dict[str, str]) -> dict[str, str]: # Filter out empty string values (cleared optional fields) config = {k: v for k, v in config.items() if v != ""} provider = self.initial_data.get("provider") @@ -104,7 +83,7 @@ def validate_project_ids(self, project_ids: list[int]) -> list[int]: def _validate_all_fields_present( self, - config: dict, + config: Mapping[str, str], required_fields: list[str] | frozenset[str], provider: DataForwarderProviderSlug, ) -> None: @@ -114,7 +93,7 @@ def _validate_all_fields_present( f"Missing required {provider.value} fields: {', '.join(missing_fields)}" ) - def _validate_sqs_queue_url(self, config: dict, errors: list[str]) -> None: + def _validate_sqs_queue_url(self, config: Mapping[str, str], errors: list[str]) -> None: queue_url = config.get("queue_url") sqs_url_pattern = ( r"^https://sqs\.[a-z0-9\-]+\.amazonaws\.com/\d+/[a-zA-Z0-9\-_/]+(?:\.fifo)?$" @@ -125,7 +104,7 @@ def _validate_sqs_queue_url(self, config: dict, errors: list[str]) -> None: "https://sqs..amazonaws.com//" ) - def _validate_sqs_region(self, config: dict, errors: list[str]) -> None: + def _validate_sqs_region(self, config: Mapping[str, str], errors: list[str]) -> None: import boto3 region = config.get("region") @@ -135,33 +114,33 @@ def _validate_sqs_region(self, config: dict, errors: list[str]) -> None: if not region or region not in valid_regions: errors.append("region must be a valid AWS region") - def _validate_sqs_message_group_id(self, config: dict, errors: list[str]) -> None: + def _validate_sqs_message_group_id(self, config: Mapping[str, str], errors: list[str]) -> None: message_group_id = config.get("message_group_id") queue_url = config.get("queue_url") if isinstance(queue_url, str) and queue_url.endswith(".fifo") and not message_group_id: errors.append("message_group_id is required for FIFO queues") - def _validate_sqs_s3_bucket(self, config: dict, errors: list[str]) -> None: + def _validate_sqs_s3_bucket(self, config: Mapping[str, str], errors: list[str]) -> None: s3_bucket = config.get("s3_bucket") if s3_bucket is not None: s3_bucket_pattern = r"^[a-z0-9\-\.]+$" if not re.match(s3_bucket_pattern, s3_bucket): errors.append("s3_bucket must be a valid S3 bucket name") - def _validate_segment_write_key(self, config: dict) -> None: + def _validate_segment_write_key(self, config: Mapping[str, str]) -> None: segment_write_key_pattern = r"^[a-zA-Z0-9_\-]+$" write_key = config.get("write_key") if not write_key or not re.match(segment_write_key_pattern, write_key): raise ValidationError("write_key must be a valid Segment write key format") - def _validate_splunk_instance_url(self, config: dict, errors: list[str]) -> None: + def _validate_splunk_instance_url(self, config: Mapping[str, str], errors: list[str]) -> None: splunk_url_pattern = r"^https?://[a-zA-Z0-9\-\.]+(?::\d+)?(?:/.*)?$" instance_url = config.get("instance_url") if not instance_url or not re.match(splunk_url_pattern, instance_url): errors.append("instance_url must be a valid URL starting with http:// or https://") - def _validate_splunk_token_format(self, config: dict, errors: list[str]) -> None: + def _validate_splunk_token_format(self, config: Mapping[str, str], errors: list[str]) -> None: token = config.get("token") if token: splunk_token_pattern = r"^[a-zA-Z0-9\-]+$" @@ -187,7 +166,7 @@ def validate(self, attrs: Mapping[str, Any]) -> Mapping[str, Any]: return attrs - def _validate_sqs_config(self, config) -> SQSConfig: + def _validate_sqs_config(self, config: dict[str, str]) -> dict[str, str]: self._validate_all_fields_present(config, SQS_REQUIRED_KEYS, DataForwarderProviderSlug.SQS) errors: list[str] = [] @@ -201,14 +180,14 @@ def _validate_sqs_config(self, config) -> SQSConfig: return config - def _validate_segment_config(self, config) -> SegmentConfig: + def _validate_segment_config(self, config: dict[str, str]) -> dict[str, str]: self._validate_all_fields_present( config, SEGMENT_REQUIRED_KEYS, DataForwarderProviderSlug.SEGMENT ) self._validate_segment_write_key(config) return config - def _validate_splunk_config(self, config) -> SplunkConfig: + def _validate_splunk_config(self, config: dict[str, str]) -> dict[str, str]: self._validate_all_fields_present( config, SPLUNK_REQUIRED_KEYS, DataForwarderProviderSlug.SPLUNK ) diff --git a/src/sentry/integrations/aws_lambda/utils.py b/src/sentry/integrations/aws_lambda/utils.py index f398dce63df3..add71f0985ec 100644 --- a/src/sentry/integrations/aws_lambda/utils.py +++ b/src/sentry/integrations/aws_lambda/utils.py @@ -67,23 +67,24 @@ # Taken from: https://gist.github.com/gene1wood/5299969edc4ef21d8efcfea52158dd40 -def parse_arn(arn): +def parse_arn(arn: str) -> dict[str, str | None]: # http://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html elements = arn.split(":", 5) - result = { + resource = elements[5] + resource_type = None + if "/" in resource: + resource_type, resource = resource.split("/", 1) + elif ":" in resource: + resource_type, resource = resource.split(":", 1) + return { "arn": elements[0], "partition": elements[1], "service": elements[2], "region": elements[3], "account": elements[4], - "resource": elements[5], - "resource_type": None, + "resource": resource, + "resource_type": resource_type, } - if "/" in result["resource"]: - result["resource_type"], result["resource"] = result["resource"].split("/", 1) - elif ":" in result["resource"]: - result["resource_type"], result["resource"] = result["resource"].split(":", 1) - return result def get_option_value(function, option): @@ -134,11 +135,11 @@ def get_option_value(function, option): return cache_value.get(registry_field) -def _get_arn_without_version(arn): +def _get_arn_without_version(arn: str) -> str: return arn.rpartition(":")[0] -def get_version_of_arn(arn): +def get_version_of_arn(arn: str) -> int: return int(arn.split(":")[-1]) @@ -163,7 +164,7 @@ def get_latest_layer_version(function): return int(get_option_value(function, OPTION_VERSION)) -def get_index_of_sentry_layer(layers, arn_to_match): +def get_index_of_sentry_layer(layers: list[str], arn_to_match: str) -> int: """ Find the index of the Sentry layer in a list of layers. If the version is different, we still consider that a match @@ -203,7 +204,7 @@ def get_dsn_for_project(organization_id, project_id): return enabled_dsn.dsn_public -def get_node_options_for_layer(layer_name: str, layer_version: int | None) -> str: +def get_node_options_for_layer(layer_name: str | None, layer_version: int | None) -> str: """ Depending on the SDK major our Lambda Layer represents, a different SDK package has to be used when setting `NODE_OPTIONS`. @@ -354,7 +355,6 @@ def disable_single_lambda(lambda_client, function, layer_arn): def update_lambda_with_retries(lambda_client, **kwargs): num_retries = DEFAULT_NUM_RETRIES - # pull off the num_retries argument if it exists if "num_retries" in kwargs: num_retries = kwargs.pop("num_retries") try: @@ -368,7 +368,7 @@ def update_lambda_with_retries(lambda_client, **kwargs): raise -def get_invalid_layer_name(err_message): +def get_invalid_layer_name(err_message: str) -> str | None: """ Check to see if an error matches the invalid layer message :param err_message: error string @@ -383,7 +383,7 @@ def get_invalid_layer_name(err_message): return None -def get_too_many_requests_error_message(err_message): +def get_too_many_requests_error_message(err_message: str) -> bool: """ Check to see if an error is a TooManyRequestsException :param err_message: error string @@ -392,7 +392,7 @@ def get_too_many_requests_error_message(err_message): return "TooManyRequestsException" in err_message -def get_missing_role_error(err_message): +def get_missing_role_error(err_message: str) -> bool: """ Check to see if an error matches the missing role text :param err_message: error string @@ -407,7 +407,7 @@ def get_missing_role_error(err_message): return err_message == missing_role_err -def get_exceeded_env_vars_limit_error(err_message): +def get_exceeded_env_vars_limit_error(err_message: str) -> bool: """ Check to see if an error matches the env vars limit text :param err_message: error string diff --git a/src/sentry/integrations/bitbucket/client.py b/src/sentry/integrations/bitbucket/client.py index c5e908d196c6..729d002659a6 100644 --- a/src/sentry/integrations/bitbucket/client.py +++ b/src/sentry/integrations/bitbucket/client.py @@ -2,7 +2,8 @@ import datetime import logging -from typing import Any +from collections.abc import Mapping, MutableMapping, Sequence +from typing import Any, cast from urllib.parse import parse_qs, urlparse, urlsplit from requests import PreparedRequest @@ -14,9 +15,10 @@ from sentry.integrations.types import IntegrationProviderSlug from sentry.integrations.utils.atlassian_connect import get_query_hash from sentry.models.repository import Repository +from sentry.shared_integrations.response import MappingApiResponse from sentry.utils import jwt from sentry.utils.http import absolute_uri -from sentry.utils.patch_set import patch_to_file_changes +from sentry.utils.patch_set import FileChange, patch_to_file_changes BITBUCKET_KEY = f"{urlparse(absolute_uri()).hostname}.bitbucket" @@ -62,7 +64,7 @@ class BitbucketApiClient(ApiClient, RepositoryClient): page_size = 100 page_number_limit = 50 - def __init__(self, integration: RpcIntegration | Integration): + def __init__(self, integration: RpcIntegration | Integration) -> None: self.shared_secret = integration.metadata["shared_secret"] # subject is probably the clientKey self.subject = integration.external_id @@ -93,30 +95,40 @@ def finalize_request(self, prepared_request: PreparedRequest) -> PreparedRequest prepared_request.headers["Authorization"] = f"JWT {encoded_jwt}" return prepared_request - def get_issue(self, repo, issue_id): - return self.get(BitbucketAPIPath.issue.format(repo=repo, issue_id=issue_id)) + def get_issue(self, repo: str, issue_id: str) -> MappingApiResponse: + response: MappingApiResponse = self.get( + BitbucketAPIPath.issue.format(repo=repo, issue_id=issue_id) + ) + return response def get_workspace_hooks(self, workspace: str): return self.get(BitbucketAPIPath.workspace_hooks.format(workspace=workspace)) - def create_issue(self, repo, data): - return self.post(path=BitbucketAPIPath.issues.format(repo=repo), data=data) + def create_issue(self, repo: str, data: Mapping[str, object]) -> MappingApiResponse: + response: MappingApiResponse = self.post( + path=BitbucketAPIPath.issues.format(repo=repo), data=data + ) + return response def search_issues(self, repo: str, query: str) -> dict[str, Any]: # Query filters can be found here: # https://developer.atlassian.com/bitbucket/api/2/reference/meta/filtering#supp-endpoints return self.get(path=BitbucketAPIPath.issues.format(repo=repo), params={"q": query}) - def create_comment(self, repo, issue_id, data): + def create_comment( + self, repo: str, issue_id: str, data: Mapping[str, object] + ) -> MappingApiResponse: # Call the method as below: # client.create_comment('repo', '1', {"content": {"raw": "Whatever you're commenting."}}) # https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Busername%7D/%7Brepo_slug%7D/issues/%7Bissue_id%7D/comments#post - return self.post( + response: MappingApiResponse = self.post( path=BitbucketAPIPath.issue_comments.format(repo=repo, issue_id=issue_id), data=data ) + return response - def get_repo(self, repo): - return self.get(BitbucketAPIPath.repository.format(repo=repo)) + def get_repo(self, repo: str) -> MappingApiResponse: + response: MappingApiResponse = self.get(BitbucketAPIPath.repository.format(repo=repo)) + return response def _get_all_from_paginated( self, @@ -157,39 +169,54 @@ def get_repos( page_number_limit=page_number_limit, ) - def search_repositories(self, username, query): - return self.get( + def search_repositories(self, username: str, query: str) -> MappingApiResponse: + response: MappingApiResponse = self.get( path=BitbucketAPIPath.repositories.format(username=username), params={"q": query} ) + return response - def create_hook(self, repo, data): - return self.post(path=BitbucketAPIPath.repository_hooks.format(repo=repo), data=data) + def create_hook(self, repo: str, data: Mapping[str, object]) -> MappingApiResponse: + response: MappingApiResponse = self.post( + path=BitbucketAPIPath.repository_hooks.format(repo=repo), data=data + ) + return response - def get_hooks(self, repo): - return self.get(path=BitbucketAPIPath.repository_hooks.format(repo=repo)) + def get_hooks(self, repo: str) -> MappingApiResponse: + response: MappingApiResponse = self.get( + path=BitbucketAPIPath.repository_hooks.format(repo=repo) + ) + return response - def delete_hook(self, repo, hook_id): - return self.delete(path=BitbucketAPIPath.repository_hook.format(repo=repo, uid=hook_id)) + def delete_hook(self, repo: str, hook_id: str) -> dict[str, object]: + response: dict[str, object] = self.delete( + path=BitbucketAPIPath.repository_hook.format(repo=repo, uid=hook_id) + ) + return response - def get_commit_filechanges(self, repo, sha): + def get_commit_filechanges(self, repo: str, sha: str) -> list[FileChange]: resp = self.get( BitbucketAPIPath.repository_diff.format(repo=repo, spec=sha), allow_text=True ) return patch_to_file_changes(resp.text) - def zip_commit_data(self, repo, commit_list): + def zip_commit_data( + self, repo: str, commit_list: Sequence[MutableMapping[str, object]] + ) -> Sequence[MutableMapping[str, object]]: for commit in commit_list: - commit.update({"patch_set": self.get_commit_filechanges(repo, commit["hash"])}) + sha = cast(str, commit["hash"]) + commit.update({"patch_set": self.get_commit_filechanges(repo, sha)}) return commit_list - def get_last_commits(self, repo, end_sha): + def get_last_commits(self, repo: str, end_sha: str) -> Sequence[Mapping[str, object]]: # return api request that fetches last ~30 commits # see https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Busername%7D/%7Brepo_slug%7D/commits/%7Brevision%7D # using end_sha as parameter data = self.get(BitbucketAPIPath.repository_commits.format(repo=repo, revision=end_sha)) return self.zip_commit_data(repo, data["values"]) - def compare_commits(self, repo, start_sha, end_sha): + def compare_commits( + self, repo: str, start_sha: str, end_sha: str + ) -> Sequence[Mapping[str, object]]: # where start_sha is oldest and end_sha is most recent # see # https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Busername%7D/%7Brepo_slug%7D/commits/%7Brevision%7D diff --git a/src/sentry/integrations/bitbucket/integration.py b/src/sentry/integrations/bitbucket/integration.py index 6f2611de8cad..f47cd8bba443 100644 --- a/src/sentry/integrations/bitbucket/integration.py +++ b/src/sentry/integrations/bitbucket/integration.py @@ -2,7 +2,7 @@ from collections.abc import Mapping from itertools import chain -from typing import Any +from typing import Any, TypedDict, cast from django.http.request import HttpRequest from django.urls import reverse @@ -104,6 +104,15 @@ scopes = ("issue:write", "pullrequest", "webhook", "repository") +class BitbucketRepoPayload(TypedDict, total=False): + full_name: str + uuid: str + + +class BitbucketSearchResponse(TypedDict, total=False): + values: list[BitbucketRepoPayload] + + class BitbucketIntegration(RepositoryIntegration[BitbucketApiClient], BitbucketIssuesSpec): codeowners_locations = [".bitbucket/CODEOWNERS"] @@ -154,8 +163,8 @@ def get_repositories( seen: OrderedSet[str] = OrderedSet() repos: list[RepositoryInfo] = [] for repo in chain( - exact_search_resp.get("values", []), - fuzzy_search_resp.get("values", []), + cast(BitbucketSearchResponse, exact_search_resp).get("values", []), + cast(BitbucketSearchResponse, fuzzy_search_resp).get("values", []), ): if repo["full_name"] not in seen: seen.add(repo["full_name"]) @@ -317,7 +326,7 @@ def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: "expect_exists": True, } - def setup(self): + def setup(self) -> None: from sentry.plugins.base import bindings bindings.add( diff --git a/src/sentry/integrations/bitbucket/issues.py b/src/sentry/integrations/bitbucket/issues.py index fb885871937d..16ed36d2709d 100644 --- a/src/sentry/integrations/bitbucket/issues.py +++ b/src/sentry/integrations/bitbucket/issues.py @@ -1,8 +1,8 @@ from __future__ import annotations import re -from collections.abc import Sequence -from typing import Any, NoReturn +from collections.abc import Mapping, Sequence +from typing import Any, NoReturn, cast from django.urls import reverse @@ -54,7 +54,7 @@ def get_persisted_default_config_fields(self) -> Sequence[str]: @all_silo_function def get_create_issue_config( - self, group: Group | None, user: User | RpcUser, **kwargs + self, group: Group | None, user: User | RpcUser, **kwargs: object ) -> list[IntegrationIssueConfigField]: kwargs["link_referrer"] = "bitbucket_integration" @@ -70,7 +70,8 @@ def get_create_issue_config( raise IntegrationFormError({"repo": "Organization not found"}) org = org_context.organization - params = kwargs.pop("params", {}) + params_obj: object = kwargs.pop("params", dict[str, object]()) + params: Mapping[str, object] = cast(Mapping[str, object], params_obj) default_repo, repo_choices = self.get_repository_choices(group, params) autocomplete_url = reverse( @@ -81,7 +82,7 @@ def get_create_issue_config( if title_field: title_field["maxLength"] = BITBUCKET_MAX_TITLE_LENGTH title_field["default"] = truncatechars( - title_field["default"], BITBUCKET_MAX_TITLE_LENGTH + cast(str, title_field["default"]), BITBUCKET_MAX_TITLE_LENGTH ) return [ @@ -112,7 +113,7 @@ def get_create_issue_config( }, ] - def get_link_issue_config(self, group: Group, **kwargs) -> list[dict[str, Any]]: + def get_link_issue_config(self, group: Group, **kwargs) -> list[IntegrationIssueConfigField]: params = kwargs.pop("params", {}) default_repo, repo_choices = self.get_repository_choices(group, params) @@ -194,7 +195,7 @@ def get_issue(self, issue_id, **kwargs): "repo": repo, } - def make_external_key(self, data): + def make_external_key(self, data: Mapping[str, str]) -> str: return "{}#{}".format(data["repo"], data["key"]) def after_link_issue(self, external_issue, **kwargs): diff --git a/src/sentry/integrations/bitbucket/repository.py b/src/sentry/integrations/bitbucket/repository.py index 1bf2ed281c87..e02a3afa4a71 100644 --- a/src/sentry/integrations/bitbucket/repository.py +++ b/src/sentry/integrations/bitbucket/repository.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any from sentry.integrations.services.repository.model import RpcRepository @@ -10,6 +10,8 @@ from sentry.locks import locks from sentry.models.apitoken import generate_token from sentry.models.options.organization_option import OrganizationOption +from sentry.models.organization import Organization +from sentry.models.repository import Repository from sentry.organizations.services.organization.model import RpcOrganization from sentry.plugins.providers import IntegrationRepositoryProvider from sentry.plugins.providers.integration_repository import RepositoryConfig @@ -39,7 +41,7 @@ def get_repository_data(self, organization, config): config["name"] = repo["full_name"] return config - def get_webhook_secret(self, organization): + def get_webhook_secret(self, organization: Organization) -> str: # TODO(LB): Revisit whether Integrations V3 should be using OrganizationOption for storage lock = locks.get( f"bitbucket:webhook-secret:{organization.id}", @@ -47,7 +49,7 @@ def get_webhook_secret(self, organization): name="bitbucket_webhook_secret", ) with lock.acquire(): - secret = OrganizationOption.objects.get_value( + secret: str | None = OrganizationOption.objects.get_value( organization=organization, key="bitbucket:webhook_secret" ) if secret is None: @@ -120,7 +122,7 @@ def on_create_repository(self, repo: RpcRepository, organization: RpcOrganizatio }, ) - def on_delete_repository(self, repo): + def on_delete_repository(self, repo: Repository) -> None: installation = self.get_installation(repo.integration_id, repo.organization_id) client = installation.get_client() @@ -145,7 +147,9 @@ def _format_commits(self, repo, commit_list): for c in commit_list ] - def compare_commits(self, repo, start_sha, end_sha): + def compare_commits( + self, repo: Repository, start_sha: str | None, end_sha: str + ) -> Sequence[Mapping[str, object]]: installation = self.get_installation(repo.integration_id, repo.organization_id) client = installation.get_client() # use config name because that is kept in sync via webhooks @@ -156,14 +160,16 @@ def compare_commits(self, repo, start_sha, end_sha): except Exception as e: installation.raise_error(e) else: - return self._format_commits(repo, res[:10]) + formatted: Sequence[Mapping[str, object]] = self._format_commits(repo, res[:10]) + return formatted else: try: res = client.compare_commits(name, start_sha, end_sha) except Exception as e: installation.raise_error(e) else: - return self._format_commits(repo, res) + formatted = self._format_commits(repo, res) + return formatted - def repository_external_slug(self, repo): + def repository_external_slug(self, repo: Repository) -> str | None: return repo.name diff --git a/src/sentry/integrations/bitbucket/search.py b/src/sentry/integrations/bitbucket/search.py index 447e6529ea89..ed8a7a3ee089 100644 --- a/src/sentry/integrations/bitbucket/search.py +++ b/src/sentry/integrations/bitbucket/search.py @@ -34,11 +34,11 @@ def repository_field(self) -> str: return "repo" @property - def integration_provider(self): + def integration_provider(self) -> str: return IntegrationProviderSlug.BITBUCKET.value @property - def installation_class(self): + def installation_class(self) -> type[BitbucketIntegration]: return BitbucketIntegration def handle_search_issues(self, installation: T, query: str, repo: str | None) -> Response: diff --git a/src/sentry/integrations/bitbucket_server/client.py b/src/sentry/integrations/bitbucket_server/client.py index a8d881f08548..8ee4da209b40 100644 --- a/src/sentry/integrations/bitbucket_server/client.py +++ b/src/sentry/integrations/bitbucket_server/client.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Mapping, Sequence from typing import Any, NotRequired, TypedDict from urllib.parse import parse_qsl @@ -15,10 +16,15 @@ from sentry.integrations.types import IntegrationProviderSlug from sentry.models.repository import Repository from sentry.shared_integrations.exceptions import ApiError +from sentry.shared_integrations.response import MappingApiResponse logger = logging.getLogger("sentry.integrations.bitbucket_server") +class _ValuesResponse(TypedDict): + values: list[Mapping[str, object]] + + class BitbucketServerWebhookFields(TypedDict): name: str url: str @@ -53,7 +59,7 @@ def __init__(self, base_url, consumer_key, private_key, verify_ssl=True, *args, self.private_key = private_key self.verify_ssl = verify_ssl - def get_request_token(self): + def get_request_token(self) -> dict[str, str]: """ Step 1 of the oauth flow. Get a request token that we can have the user verify. @@ -62,14 +68,16 @@ def get_request_token(self): resp = self.post(url, allow_text=True) return dict(parse_qsl(resp.text)) - def get_authorize_url(self, request_token): + def get_authorize_url(self, request_token: Mapping[str, str]) -> str: """ Step 2 of the oauth flow. Get a URL that the user can verify our request token at. """ return self.authorize_url.format(self.base_url, request_token["oauth_token"]) - def get_access_token(self, request_token, verifier): + def get_access_token( + self, request_token: Mapping[str, str], verifier: str | None + ) -> dict[str, str]: """ Step 3 of the oauth flow. Use the verifier and request token from step 1 to get an access token. @@ -120,7 +128,7 @@ def __init__( self, integration: RpcIntegration | Integration, identity: RpcIdentity, - ): + ) -> None: self.base_url = integration.metadata["base_url"] self.identity = identity @@ -133,7 +141,7 @@ def __init__( def finalize_request(self, prepared_request: PreparedRequest) -> PreparedRequest: return self.authorize_request(prepared_request=prepared_request) - def authorize_request(self, prepared_request: PreparedRequest): + def authorize_request(self, prepared_request: PreparedRequest) -> PreparedRequest: """Bitbucket Server authorizes with RSA-signed OAuth1 scheme""" if not self.identity: return prepared_request @@ -149,17 +157,19 @@ def authorize_request(self, prepared_request: PreparedRequest): prepared_request.prepare_auth(auth=auth_scheme) return prepared_request - def get_repos(self): - return self.get( + def get_repos(self) -> MappingApiResponse: + response: MappingApiResponse = self.get( BitbucketServerAPIPath.repositories, params={"limit": 250, "permission": "REPO_ADMIN"}, ) + return response - def search_repositories(self, query_string): - return self.get( + def search_repositories(self, query_string: str) -> MappingApiResponse: + response: MappingApiResponse = self.get( BitbucketServerAPIPath.repositories, params={"limit": 250, "permission": "REPO_ADMIN", "name": query_string}, ) + return response def get_repo(self, project: str, repo: str) -> dict[str, Any]: return self.get( @@ -204,7 +214,9 @@ def delete_hook(self, project: str, repo: str, webhook_id: int | str) -> None: ), ) - def get_commits(self, project, repo, from_hash, to_hash, limit=1000): + def get_commits( + self, project: str, repo: str, from_hash: str, to_hash: str, limit: int = 1000 + ) -> Sequence[Mapping[str, object]]: logger.info( "load.commits", extra={ @@ -220,13 +232,18 @@ def get_commits(self, project, repo, from_hash, to_hash, limit=1000): {"limit": limit, "since": from_hash, "until": to_hash, "merges": "exclude"}, ) - def get_last_commits(self, project, repo, limit=10): - return self.get( + def get_last_commits( + self, project: str, repo: str, limit: int = 10 + ) -> Sequence[Mapping[str, object]]: + data: _ValuesResponse = self.get( BitbucketServerAPIPath.repository_commits.format(project=project, repo=repo), params={"merges": "exclude", "limit": limit}, - )["values"] + ) + return data["values"] - def get_commit_filechanges(self, project, repo, commit, limit=1000): + def get_commit_filechanges( + self, project: str, repo: str, commit: str, limit: int = 1000 + ) -> Sequence[Mapping[str, object]]: logger.info( "load.filechanges", extra={ @@ -241,8 +258,10 @@ def get_commit_filechanges(self, project, repo, commit, limit=1000): {"limit": limit}, ) - def _get_values(self, uri, params, max_pages=1000000): - values = [] + def _get_values( + self, uri: str, params: Mapping[str, object], max_pages: int = 1000000 + ) -> list[Mapping[str, object]]: + values: list[Mapping[str, object]] = [] start = 0 logger.info( @@ -255,7 +274,7 @@ def _get_values(self, uri, params, max_pages=1000000): ) for i in range(max_pages): - new_params = dict.copy(params) + new_params = dict(params) new_params["start"] = start logger.debug( "Loading values for paginated uri starting from %s", diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 9919d5e3f93f..455d59b77123 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from urllib.parse import parse_qs, quote, urlencode, urlparse from cryptography.hazmat.backends import default_backend @@ -247,6 +247,21 @@ def handle_post( return PipelineStepResult.advance() +class BitbucketServerProjectPayload(TypedDict, total=False): + key: str + name: str + + +class BitbucketServerRepoPayload(TypedDict, total=False): + slug: str + name: str + project: BitbucketServerProjectPayload + + +class BitbucketServerReposResponse(TypedDict, total=False): + values: list[BitbucketServerRepoPayload] + + class BitbucketServerIntegration(RepositoryIntegration[BitbucketServerClient]): """ IntegrationInstallation implementation for Bitbucket Server @@ -302,7 +317,7 @@ def get_repositories( "repo": repo["slug"], "name": repo["project"]["name"] + "/" + repo["name"], } - for repo in resp.get("values", []) + for repo in cast(BitbucketServerReposResponse, resp).get("values", []) ] resp = self.get_client().search_repositories(query) @@ -315,7 +330,7 @@ def get_repositories( "repo": repo["slug"], "name": repo["project"]["name"] + "/" + repo["name"], } - for repo in resp.get("values", []) + for repo in cast(BitbucketServerReposResponse, resp).get("values", []) ] def has_repo_access(self, repo: RpcRepository) -> bool: diff --git a/src/sentry/integrations/github/issues.py b/src/sentry/integrations/github/issues.py index 3aa1894b323e..64b9909cb6c0 100644 --- a/src/sentry/integrations/github/issues.py +++ b/src/sentry/integrations/github/issues.py @@ -3,7 +3,7 @@ import re from collections.abc import Mapping, Sequence from operator import attrgetter -from typing import Any, NoReturn +from typing import Any, NoReturn, cast from django.urls import reverse @@ -158,7 +158,7 @@ def create_default_repo_choice(self, default_repo: str) -> tuple[str, str]: @all_silo_function def get_create_issue_config( - self, group: Group | None, user: User | RpcUser, **kwargs: Any + self, group: Group | None, user: User | RpcUser, **kwargs: object ) -> list[IntegrationIssueConfigField]: """ We use the `group` to get three things: organization_slug, project @@ -185,7 +185,8 @@ def get_create_issue_config( assert org_context is not None org = org_context.organization - params = kwargs.pop("params", {}) + params_obj: object = kwargs.pop("params", dict[str, object]()) + params: Mapping[str, object] = cast(Mapping[str, object], params_obj) prefetch_options = features.has("organizations:github-issue-form-prefetch", org, actor=user) if prefetch_options: defaults = self.get_project_defaults(group.project_id) if group else {} diff --git a/src/sentry/integrations/github/types.py b/src/sentry/integrations/github/types.py index 1e6d140ca5a5..f8b63b94ef5b 100644 --- a/src/sentry/integrations/github/types.py +++ b/src/sentry/integrations/github/types.py @@ -13,6 +13,6 @@ class GitHubIssueStatus(StrEnum): CLOSED = "closed" @classmethod - def get_choices(cls): + def get_choices(cls) -> list[tuple[str, str]]: """Return choices formatted for dropdown selectors""" return [(status.value, status.value.capitalize()) for status in cls] diff --git a/src/sentry/integrations/gitlab/client.py b/src/sentry/integrations/gitlab/client.py index 28f79991cc52..9a9088d97f28 100644 --- a/src/sentry/integrations/gitlab/client.py +++ b/src/sentry/integrations/gitlab/client.py @@ -5,7 +5,7 @@ from datetime import UTC, datetime, timedelta from enum import StrEnum from time import time -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast from urllib.parse import quote from django.urls import reverse @@ -28,6 +28,7 @@ from sentry.models.repository import Repository from sentry.shared_integrations.client.proxy import IntegrationProxyClient from sentry.shared_integrations.exceptions import ApiError, ApiUnauthorized +from sentry.shared_integrations.response import MappingApiResponse, SequenceApiResponse from sentry.silo.base import SiloMode, control_silo_function from sentry.users.models.identity import Identity from sentry.utils import metrics @@ -67,6 +68,13 @@ def _expires_soon(identity_data: Mapping[str, Any], requested_margin_s: float) - return expires is None or int(expires) < time() + margin_s +class GitLabMetadata(TypedDict): + base_url: str + verify_ssl: bool + webhook_secret: NotRequired[str] + include_subgroups: NotRequired[bool] + + class GitLabApiRequestType(StrEnum): CREATE_PROJECT_WEBHOOK = "create_project_webhook" UPDATE_PROJECT_WEBHOOK = "update_project_webhook" @@ -82,7 +90,7 @@ class GitLabSetupApiClient(IntegrationProxyClient): integration_name = "gitlab_setup" - def __init__(self, base_url: str, access_token: str, verify_ssl: bool): + def __init__(self, base_url: str, access_token: str, verify_ssl: bool) -> None: super().__init__(verify_ssl=verify_ssl) self.base_url = base_url self.token = access_token @@ -98,7 +106,7 @@ def authorize_request(self, prepared_request: PreparedRequest) -> PreparedReques return prepared_request @control_silo_function - def get_group(self, group): + def get_group(self, group: str) -> MappingApiResponse: """Get a group based on `path` which is a slug. We need to URL quote because subgroups use `/` in their @@ -106,11 +114,12 @@ def get_group(self, group): """ group = quote(group, safe="") path = GitLabApiClientPath.group.format(group=group) - return self.get(path) + response: MappingApiResponse = self.get(path) + return response class GitLabApiClient(IntegrationProxyClient, RepositoryClient, CommitContextClient): - def __init__(self, installation: GitlabIntegration): + def __init__(self, installation: GitlabIntegration) -> None: self.installation = installation verify_ssl = self.metadata["verify_ssl"] self.is_refreshing_token = False @@ -129,8 +138,8 @@ def identity(self) -> RpcIdentity: return self.installation.default_identity @property - def metadata(self): - return self.installation.model.metadata + def metadata(self) -> GitLabMetadata: + return cast(GitLabMetadata, self.installation.model.metadata) def build_url(self, path: str) -> str: path = GitLabApiClientPath.build_api_url(self.base_url, path) @@ -231,18 +240,19 @@ def _refresh_auth(self) -> None: verify_ssl=self.metadata["verify_ssl"], ) - def request(self, *args: Any, **kwargs: Any): + def request(self, *args: Any, **kwargs: Any) -> object: kwargs.pop("credentials_set", None) if SiloMode.get_current_mode() == SiloMode.CELL: # Skip token refreshes in Cell silo, as these will # be handled below by the control silo when the # integration proxy invokes the client code. - return super().request(*args, **kwargs) + response: object = super().request(*args, **kwargs) + return response return self._issue_request_with_auto_token_refresh(*args, **kwargs) - def _issue_request_with_auto_token_refresh(self, *args: Any, **kwargs: Any): + def _issue_request_with_auto_token_refresh(self, *args: Any, **kwargs: Any) -> object: try: response = super().request(*args, **kwargs) except ApiUnauthorized: @@ -260,9 +270,10 @@ def _issue_request_with_auto_token_refresh(self, *args: Any, **kwargs: Any): # refresh the token if an auth error occurs. return self._attempt_request_after_refreshing_token(*args, **kwargs) - return response + response_object: object = response + return response_object - def _attempt_request_after_refreshing_token(self, *args: Any, **kwargs: Any): + def _attempt_request_after_refreshing_token(self, *args: Any, **kwargs: Any) -> object: assert not self.is_refreshing_token, "A token refresh is already occurring" self.is_refreshing_token = True try: @@ -277,19 +288,23 @@ def _attempt_request_after_refreshing_token(self, *args: Any, **kwargs: Any): finally: self.is_refreshing_token = False - def get_user(self): + def get_user(self) -> MappingApiResponse: """Get a user See https://docs.gitlab.com/ee/api/users.html#single-user """ - return self.get(GitLabApiClientPath.user) + response: MappingApiResponse = self.get(GitLabApiClientPath.user) + return response - def search_users(self, username: str): + def search_users(self, username: str) -> SequenceApiResponse: """Search for a user by username See https://docs.gitlab.com/ee/api/users.html#for-non-administrator-users """ - return self.get(GitLabApiClientPath.users, params={"username": username}) + response: SequenceApiResponse = self.get( + GitLabApiClientPath.users, params={"username": username} + ) + return response def search_projects(self, group=None, query=None, simple=True): """Get projects @@ -326,174 +341,210 @@ def get_results(resp): return self.get_with_pagination(path, gen_params, get_results) - def get_project(self, project_id): + def get_project(self, project_id: str | int) -> MappingApiResponse: """Get project See https://docs.gitlab.com/ee/api/projects.html#get-single-project """ - return self.get(GitLabApiClientPath.project.format(project=safe_quote(project_id))) + response: MappingApiResponse = self.get( + GitLabApiClientPath.project.format(project=safe_quote(project_id)) + ) + return response - def get_issue(self, project_id, issue_id): + def get_issue(self, project_id: str | int, issue_id: str | int) -> MappingApiResponse: """Get an issue See https://docs.gitlab.com/ee/api/issues.html#single-issue """ try: - return self.get( + response: MappingApiResponse = self.get( GitLabApiClientPath.issue.format(project=safe_quote(project_id), issue=issue_id) ) + return response except IndexError: raise ApiError("Issue not found with ID", 404) - def create_issue(self, project, data): + def create_issue(self, project: str | int, data: dict[str, object]) -> MappingApiResponse: """Create an issue See https://docs.gitlab.com/ee/api/issues.html#new-issue """ - return self.post(GitLabApiClientPath.issues.format(project=safe_quote(project)), data=data) + response: MappingApiResponse = self.post( + GitLabApiClientPath.issues.format(project=safe_quote(project)), data=data + ) + return response - def get_issue_awards(self, project_id: str, issue_id: str): - return self.get( + def get_issue_awards(self, project_id: str | int, issue_id: str) -> SequenceApiResponse: + response: SequenceApiResponse = self.get( GitLabApiClientPath.issue_awards.format( project_id=safe_quote(project_id), issue_id=issue_id ) ) + return response - def create_issue_award(self, project_id: str, issue_id: str, emoji: str): - return self.post( + def create_issue_award( + self, project_id: str | int, issue_id: str, emoji: str + ) -> MappingApiResponse: + response: MappingApiResponse = self.post( GitLabApiClientPath.issue_awards.format( project_id=safe_quote(project_id), issue_id=issue_id ), params={"name": emoji}, ) + return response - def delete_issue_award(self, project_id: str, issue_id: str, award_id: str): - return self.delete( + def delete_issue_award( + self, project_id: str | int, issue_id: str, award_id: str + ) -> dict[str, object]: + response: dict[str, object] = self.delete( GitLabApiClientPath.issue_award.format( project_id=safe_quote(project_id), issue_id=issue_id, award_id=award_id ) ) + return response - def get_issue_notes(self, project_id: str, issue_id: str): + def get_issue_notes(self, project_id: str | int, issue_id: str) -> SequenceApiResponse: """https://docs.gitlab.com/api/notes/#list-all-issue-notes""" - return self.get( + response: SequenceApiResponse = self.get( GitLabApiClientPath.issue_notes.format( project_id=safe_quote(project_id), issue_id=issue_id ) ) + return response - def create_comment(self, repo: str, issue_id: str, data: dict[str, Any]): + def create_comment(self, repo: str, issue_id: str, data: dict[str, Any]) -> MappingApiResponse: """Create an issue note/comment See https://docs.gitlab.com/ee/api/notes.html#create-new-issue-note """ - return self.post( + response: MappingApiResponse = self.post( GitLabApiClientPath.issue_notes.format(project_id=safe_quote(repo), issue_id=issue_id), data=data, ) + return response - def update_comment(self, repo: str, issue_id: str, comment_id: str, data: dict[str, Any]): + def update_comment( + self, repo: str, issue_id: str, comment_id: str, data: dict[str, Any] + ) -> MappingApiResponse: """Modify existing issue note See https://docs.gitlab.com/ee/api/notes.html#modify-existing-issue-note """ - return self.put( + response: MappingApiResponse = self.put( GitLabApiClientPath.issue_note.format( project_id=safe_quote(repo), issue_id=issue_id, note_id=comment_id ), data=data, ) + return response - def delete_issue_note(self, repo: str, issue_id: str, comment_id: str): + def delete_issue_note(self, repo: str, issue_id: str, comment_id: str) -> dict[str, object]: """https://docs.gitlab.com/api/notes/#delete-an-issue-note""" - return self.delete( + response: dict[str, object] = self.delete( GitLabApiClientPath.issue_note.format( project_id=safe_quote(repo), issue_id=issue_id, note_id=comment_id ) ) + return response - def get_issue_note_awards(self, repo: str, issue_id: str, note_id: str): - return self.get( + def get_issue_note_awards(self, repo: str, issue_id: str, note_id: str) -> SequenceApiResponse: + response: SequenceApiResponse = self.get( GitLabApiClientPath.issue_note_awards.format( project_id=safe_quote(repo), issue_id=issue_id, note_id=note_id ) ) + return response - def create_issue_note_award(self, repo: str, issue_id: str, note_id: str, emoji: str): - return self.post( + def create_issue_note_award( + self, repo: str, issue_id: str, note_id: str, emoji: str + ) -> MappingApiResponse: + response: MappingApiResponse = self.post( GitLabApiClientPath.issue_note_awards.format( project_id=safe_quote(repo), issue_id=issue_id, note_id=note_id ), params={"name": emoji}, ) + return response - def delete_issue_note_award(self, repo: str, issue_id: str, note_id: str, award_id: str): - return self.delete( + def delete_issue_note_award( + self, repo: str, issue_id: str, note_id: str, award_id: str + ) -> dict[str, object]: + response: dict[str, object] = self.delete( GitLabApiClientPath.issue_note_award.format( project_id=safe_quote(repo), issue_id=issue_id, note_id=note_id, award_id=award_id ) ) + return response - def get_merge_request(self, project_id: str, pr_key: str) -> Any: - return self.get( + def get_merge_request(self, project_id: str, pr_key: str) -> MappingApiResponse: + response: MappingApiResponse = self.get( GitLabApiClientPath.merge_request.format( project_id=safe_quote(project_id), pr_key=pr_key ) ) + return response - def get_merge_requests(self, project_id: str, state: str | None = None) -> Any: + def get_merge_requests(self, project_id: str, state: str | None = None) -> SequenceApiResponse: if state is None: params = None else: params = {"state": state} - return self.get( + response: SequenceApiResponse = self.get( GitLabApiClientPath.merge_requests.format(project_id=safe_quote(project_id)), params=params, ) + return response - def create_merge_request(self, project_id: str, data: dict[str, Any]) -> Any: - return self.post( + def create_merge_request(self, project_id: str, data: dict[str, Any]) -> MappingApiResponse: + response: MappingApiResponse = self.post( GitLabApiClientPath.merge_requests.format(project_id=safe_quote(project_id)), data=data, ) + return response - def update_merge_request(self, project_id: str, pr_key: str, data: dict[str, Any]) -> Any: - return self.put( + def update_merge_request( + self, project_id: str, pr_key: str, data: dict[str, Any] + ) -> MappingApiResponse: + response: MappingApiResponse = self.put( GitLabApiClientPath.merge_request.format( project_id=safe_quote(project_id), pr_key=pr_key ), data=data, ) + return response - def get_merge_request_notes(self, project_id: str, pr_key: str) -> Any: - return self.get( + def get_merge_request_notes(self, project_id: str, pr_key: str) -> SequenceApiResponse: + response: SequenceApiResponse = self.get( GitLabApiClientPath.merge_request_notes.format( project_id=safe_quote(project_id), pr_key=pr_key ) ) + return response - def get_merge_request_versions(self, project_id: str, pr_key: str) -> Any: - return self.get( + def get_merge_request_versions(self, project_id: str, pr_key: str) -> SequenceApiResponse: + response: SequenceApiResponse = self.get( GitLabApiClientPath.merge_request_versions.format( project_id=safe_quote(project_id), pr_key=pr_key ) ) + return response def create_merge_request_discussion( self, project_id: str, pr_key: str, data: dict[str, Any] - ) -> Any: - return self.post( + ) -> MappingApiResponse: + response: MappingApiResponse = self.post( GitLabApiClientPath.merge_request_discussions.format( project_id=safe_quote(project_id), pr_key=pr_key ), data=data, ) + return response def create_merge_request_discussion_note( self, project_id: str, pr_key: str, discussion_id: str, data: dict[str, Any] - ) -> Any: - return self.post( + ) -> MappingApiResponse: + response: MappingApiResponse = self.post( GitLabApiClientPath.merge_request_discussion_notes.format( project_id=safe_quote(project_id), pr_key=pr_key, @@ -501,11 +552,12 @@ def create_merge_request_discussion_note( ), data=data, ) + return response def resolve_merge_request_discussion( - self, project_id: str, pr_key: str, discussion_id: str, resolved=True - ) -> Any: - return self.put( + self, project_id: str, pr_key: str, discussion_id: str, resolved: bool = True + ) -> MappingApiResponse: + response: MappingApiResponse = self.put( GitLabApiClientPath.merge_request_discussion.format( project_id=safe_quote(project_id), pr_key=pr_key, @@ -513,19 +565,25 @@ def resolve_merge_request_discussion( ), params={"resolved": resolved}, ) + return response - def create_pr_comment(self, repo: Repository, pr: PullRequest, data: dict[str, Any]) -> Any: + def create_pr_comment( + self, repo: Repository, pr: PullRequest, data: dict[str, Any] + ) -> MappingApiResponse: return self.create_merge_request_note( project_id=repo.config["project_id"], pr_key=pr.key, data=data ) - def create_merge_request_note(self, project_id: str, pr_key: str, data: dict[str, Any]) -> Any: - return self.post( + def create_merge_request_note( + self, project_id: str, pr_key: str, data: dict[str, Any] + ) -> MappingApiResponse: + response: MappingApiResponse = self.post( GitLabApiClientPath.merge_request_notes.format( project_id=safe_quote(project_id), pr_key=pr_key ), data=data, ) + return response def update_pr_comment( self, @@ -533,70 +591,83 @@ def update_pr_comment( pr: PullRequest, pr_comment: PullRequestComment, data: dict[str, Any], - ) -> Any: + ) -> MappingApiResponse: project_id = repo.config["project_id"] url = GitLabApiClientPath.merge_request_note.format( project_id=safe_quote(project_id), pr_key=pr.key, note_id=pr_comment.external_id, ) - return self.put(url, data=data) + response: MappingApiResponse = self.put(url, data=data) + return response def delete_merge_request_note( self, project_id: str, pr_key: str, note_id: str, - ) -> Any: - return self.delete( + ) -> dict[str, object]: + response: dict[str, object] = self.delete( GitLabApiClientPath.merge_request_note.format( project_id=safe_quote(project_id), pr_key=pr_key, note_id=note_id ) ) + return response - def get_merge_request_awards(self, project_id: str, pr_key: str): - return self.get( + def get_merge_request_awards(self, project_id: str, pr_key: str) -> SequenceApiResponse: + response: SequenceApiResponse = self.get( GitLabApiClientPath.merge_request_awards.format( project_id=safe_quote(project_id), pr_key=pr_key ) ) + return response - def create_merge_request_award(self, project_id: str, pr_key: str, emoji: str): - return self.post( + def create_merge_request_award( + self, project_id: str, pr_key: str, emoji: str + ) -> MappingApiResponse: + response: MappingApiResponse = self.post( GitLabApiClientPath.merge_request_awards.format( project_id=safe_quote(project_id), pr_key=pr_key ), params={"name": emoji}, ) + return response - def delete_merge_request_award(self, project_id: str, pr_key: str, award_id: str): - return self.delete( + def delete_merge_request_award( + self, project_id: str, pr_key: str, award_id: str + ) -> dict[str, object]: + response: dict[str, object] = self.delete( GitLabApiClientPath.merge_request_award.format( project_id=safe_quote(project_id), pr_key=pr_key, award_id=award_id ) ) + return response - def get_merge_request_note_awards(self, project_id: str, pr_key: str, note_id: str): - return self.get( + def get_merge_request_note_awards( + self, project_id: str, pr_key: str, note_id: str + ) -> SequenceApiResponse: + response: SequenceApiResponse = self.get( GitLabApiClientPath.merge_request_note_awards.format( project_id=safe_quote(project_id), pr_key=pr_key, note_id=note_id ) ) + return response def create_merge_request_note_award( self, project_id: str, pr_key: str, note_id: str, emoji: str - ): - return self.post( + ) -> MappingApiResponse: + response: MappingApiResponse = self.post( GitLabApiClientPath.merge_request_note_awards.format( project_id=safe_quote(project_id), pr_key=pr_key, note_id=note_id ), params={"name": emoji}, ) + return response def delete_merge_request_note_award( self, project_id: str, pr_key: str, note_id: str, award_id: str - ): - return self.delete( + ) -> dict[str, object]: + response: dict[str, object] = self.delete( GitLabApiClientPath.merge_request_note_award.format( project_id=safe_quote(project_id), pr_key=pr_key, @@ -604,32 +675,44 @@ def delete_merge_request_note_award( award_id=award_id, ) ) + return response - def search_project_issues(self, project_id, query, iids=None): + def search_project_issues( + self, project_id: str | int, query: str | None, iids: Sequence[str] | None = None + ) -> SequenceApiResponse: """Search issues in a project See https://docs.gitlab.com/ee/api/issues.html#list-project-issues """ path = GitLabApiClientPath.project_issues.format(project=safe_quote(project_id)) - return self.get(path, params={"scope": "all", "search": query, "iids": iids}) + response: SequenceApiResponse = self.get( + path, params={"scope": "all", "search": query, "iids": iids} + ) + return response - def update_issue_assignees(self, project_id: str, issue_iid: str, assignee_ids: list[int]): + def update_issue_assignees( + self, project_id: str | int, issue_iid: str, assignee_ids: list[int] + ) -> MappingApiResponse: """Update an issue's assignees See https://docs.gitlab.com/ee/api/issues.html#edit-an-issue """ data = {"assignee_ids": assignee_ids} path = GitLabApiClientPath.issue.format(project=safe_quote(project_id), issue=issue_iid) - return self.put(path, data=data) + response: MappingApiResponse = self.put(path, data=data) + return response - def update_issue_status(self, project_id: str, issue_iid: str, state: str): + def update_issue_status( + self, project_id: str | int, issue_iid: str, state: str + ) -> MappingApiResponse: """Update an issue's status See https://docs.gitlab.com/ee/api/issues.html#edit-an-issue """ path = GitLabApiClientPath.issue.format(project=safe_quote(project_id), issue=issue_iid) - return self.put(path, data={"state_event": state}) + response: MappingApiResponse = self.put(path, data={"state_event": state}) + return response def _project_webhook_data(self) -> dict[str, Any]: hook_uri = reverse("sentry-extensions-gitlab-webhook") @@ -645,30 +728,34 @@ def _project_webhook_data(self) -> dict[str, Any]: "enable_ssl_verification": model.metadata["verify_ssl"], } - def create_project_webhook(self, project_id): + def create_project_webhook(self, project_id: str | int) -> int: """Create a webhook on a project. See https://docs.gitlab.com/ee/api/projects.html#add-project-hook """ path = GitLabApiClientPath.project_hooks.format(project=safe_quote(project_id)) - resp = self.post( + resp: MappingApiResponse = self.post( path, data=self._project_webhook_data(), timeout=GITLAB_WEBHOOK_REQUEST_TIMEOUT, api_request_type=GitLabApiRequestType.CREATE_PROJECT_WEBHOOK, ) - return resp["id"] + webhook_id: int = resp["id"] + return webhook_id - def get_project_webhooks(self, project_id): + def get_project_webhooks(self, project_id: str | int) -> SequenceApiResponse: """List webhooks for a project See https://docs.gitlab.com/ee/api/projects.html#list-project-hooks """ path = GitLabApiClientPath.project_hooks.format(project=safe_quote(project_id)) - return self.get(path) + response: SequenceApiResponse = self.get(path) + return response - def update_project_webhook(self, project_id, hook_id): + def update_project_webhook( + self, project_id: str | int, hook_id: str | int + ) -> MappingApiResponse: """Update a webhook on a project to include all required events See https://docs.gitlab.com/ee/api/projects.html#edit-project-hook @@ -676,12 +763,13 @@ def update_project_webhook(self, project_id, hook_id): path = GitLabApiClientPath.project_hook.format( project=safe_quote(project_id), hook_id=hook_id ) - return self.put( + response: MappingApiResponse = self.put( path, data=self._project_webhook_data(), timeout=GITLAB_WEBHOOK_REQUEST_TIMEOUT, api_request_type=GitLabApiRequestType.UPDATE_PROJECT_WEBHOOK, ) + return response def ensure_project_webhook( self, @@ -700,7 +788,8 @@ def ensure_project_webhook( if e.code != 404: raise else: - if hook.get("alert_status") != "disabled": + hook_data: Mapping[str, object] = hook + if hook_data.get("alert_status") != "disabled": outcome = "updated" return hook_id # PUT does not re-enable permanently disabled hooks. Temporarily @@ -723,7 +812,9 @@ def ensure_project_webhook( finally: metrics.incr("gitlab.project_webhook.reconcile", tags={"outcome": outcome}) - def delete_project_webhook(self, project_id, hook_id): + def delete_project_webhook( + self, project_id: str | int, hook_id: str | int + ) -> dict[str, object]: """Delete a webhook from a project See https://docs.gitlab.com/ee/api/projects.html#delete-project-hook @@ -731,28 +822,33 @@ def delete_project_webhook(self, project_id, hook_id): path = GitLabApiClientPath.project_hook.format( project=safe_quote(project_id), hook_id=hook_id ) - return self.delete( + response: dict[str, object] = self.delete( path, timeout=GITLAB_WEBHOOK_REQUEST_TIMEOUT, api_request_type=GitLabApiRequestType.DELETE_PROJECT_WEBHOOK, ) + return response - def create_branch(self, project_id: str, branch: str, ref: str): + def create_branch(self, project_id: str | int, branch: str, ref: str) -> MappingApiResponse: """https://docs.gitlab.com/api/branches/#create-repository-branch""" - return self.post( + response: MappingApiResponse = self.post( GitLabApiClientPath.branches.format(project_id=safe_quote(project_id)), params={"branch": branch, "ref": ref}, ) + return response - def get_branch(self, project_id: str, branch: str): + def get_branch(self, project_id: str | int, branch: str) -> MappingApiResponse: """https://docs.gitlab.com/api/branches/#retrieve-a-repository-branch""" - return self.get( + response: MappingApiResponse = self.get( GitLabApiClientPath.branch.format( project_id=safe_quote(project_id), branch=quote(branch, safe="") ) ) + return response - def get_last_commits(self, project_id, end_sha): + def get_last_commits( + self, project_id: str | int, end_sha: str + ) -> SequenceApiResponse | list[object]: """Get the last set of commits ending at end_sha Gitlab doesn't give us a good way to do this, so we fetch the end_sha @@ -763,24 +859,28 @@ def get_last_commits(self, project_id, end_sha): https://docs.gitlab.com/ee/api/commits.html#list-repository-commits """ path = GitLabApiClientPath.commit.format(project=safe_quote(project_id), sha=end_sha) - commit = self.get(path) + commit: MappingApiResponse = self.get(path) if not commit: return [] - end_date = commit["created_at"] + end_date: str = commit["created_at"] path = GitLabApiClientPath.commits.format(project=safe_quote(project_id)) - return self.get(path, params={"until": end_date}) + response: SequenceApiResponse = self.get(path, params={"until": end_date}) + return response - def get_commit(self, project_id, sha): + def get_commit(self, project_id: str | int, sha: str) -> MappingApiResponse: """ Get the details of a commit See https://docs.gitlab.com/ee/api/commits.html#get-a-single-commit """ - return self.get_cached( + response: MappingApiResponse = self.get_cached( GitLabApiClientPath.commit.format(project=safe_quote(project_id), sha=sha) ) + return response - def get_commits(self, project_id, ref: str | None, path: str | None): + def get_commits( + self, project_id: str | int, ref: str | None, path: str | None + ) -> SequenceApiResponse: """ Get the set of commits ending at ref. See https://docs.gitlab.com/ee/api/commits.html#list-repository-commits @@ -790,10 +890,11 @@ def get_commits(self, project_id, ref: str | None, path: str | None): params["ref_name"] = ref if path: params["path"] = path - return self.get( + response: SequenceApiResponse = self.get( GitLabApiClientPath.commits.format(project=safe_quote(project_id)), params=params, ) + return response def get_merge_commit_sha_from_commit(self, repo: Repository, sha: str) -> str | None: """ @@ -804,7 +905,7 @@ def get_merge_commit_sha_from_commit(self, repo: Repository, sha: str) -> str | path = GitLabApiClientPath.commit_merge_requests.format( project=safe_quote(project_id), sha=sha ) - response = self.get(path) + response: SequenceApiResponse = self.get(path) # Filter out non-merged merge requests merge_requests = [] @@ -817,23 +918,30 @@ def get_merge_commit_sha_from_commit(self, repo: Repository, sha: str) -> str | return None merge_request = merge_requests[0] - return merge_request["merge_commit_sha"] or merge_request["squash_commit_sha"] + merge_commit_sha: str | None = ( + merge_request["merge_commit_sha"] or merge_request["squash_commit_sha"] + ) + return merge_commit_sha - def compare_commits(self, project_id, start_sha, end_sha): + def compare_commits( + self, project_id: str | int, start_sha: str, end_sha: str + ) -> MappingApiResponse: """Compare commits between two SHAs See https://docs.gitlab.com/ee/api/repositories.html#compare-branches-tags-or-commits """ path = GitLabApiClientPath.compare.format(project=safe_quote(project_id)) - return self.get(path, params={"from": start_sha, "to": end_sha}) + response: MappingApiResponse = self.get(path, params={"from": start_sha, "to": end_sha}) + return response - def get_diff(self, project_id, sha): + def get_diff(self, project_id: str | int, sha: str) -> SequenceApiResponse: """Get the diff for a commit See https://docs.gitlab.com/ee/api/commits.html#get-the-diff-of-a-commit """ path = GitLabApiClientPath.diff.format(project=safe_quote(project_id), sha=sha) - return self.get(path) + response: SequenceApiResponse = self.get(path) + return response def check_file(self, repo: Repository, path: str, version: str | None) -> object | None: """Fetch a file for stacktrace linking @@ -852,13 +960,16 @@ def check_file(self, repo: Repository, path: str, version: str | None) -> object # Gitlab can return 404 or 400 if the file doesn't exist return self.head_cached(request_path, params={"ref": version}) - def get_file_content(self, project_id: str, path: str, ref: str | None): - return self.get( + def get_file_content( + self, project_id: str | int, path: str, ref: str | None + ) -> MappingApiResponse: + response: MappingApiResponse = self.get( GitLabApiClientPath.file.format( project=safe_quote(project_id), path=quote(path, safe="") ), params={"ref": ref}, ) + return response def get_file( self, repo: Repository, path: str, ref: str | None, codeowners: bool = False @@ -895,16 +1006,17 @@ def get_blame_for_files( }, ) - def get_pr_diffs(self, repo: Repository, pr: PullRequest) -> list[dict[str, Any]]: + def get_pr_diffs(self, repo: Repository, pr: PullRequest) -> SequenceApiResponse: project_id = repo.config["project_id"] path = GitLabApiClientPath.build_pr_diffs( project=safe_quote(project_id), pr_key=pr.key, unidiff=True ) - return self.get(path) + response: SequenceApiResponse = self.get(path) + return response def get_repository_tree( self, project_id: str, ref: str, recursive: bool = True - ) -> list[dict[str, Any]]: + ) -> SequenceApiResponse: """List repository tree at a given ref. See https://docs.gitlab.com/ee/api/repositories.html#list-repository-tree @@ -912,18 +1024,21 @@ def get_repository_tree( params: dict[str, str] = {"ref": ref, "per_page": "100"} if recursive: params["recursive"] = "true" - return self.get( + response: SequenceApiResponse = self.get( GitLabApiClientPath.tree.format(project=safe_quote(project_id)), params=params ) + return response - def get_merge_request_diffs(self, project_id: str, pr_key: str) -> list[dict[str, Any]]: - return self.get( + def get_merge_request_diffs(self, project_id: str, pr_key: str) -> SequenceApiResponse: + response: SequenceApiResponse = self.get( GitLabApiClientPath.pr_diffs.format(project=safe_quote(project_id), pr_key=pr_key) ) + return response - def get_merge_request_commits(self, project_id: str, pr_key: str) -> list[dict[str, Any]]: - return self.get( + def get_merge_request_commits(self, project_id: str, pr_key: str) -> SequenceApiResponse: + response: SequenceApiResponse = self.get( GitLabApiClientPath.merge_request_commits.format( project_id=safe_quote(project_id), pr_key=pr_key ) ) + return response diff --git a/src/sentry/integrations/gitlab/integration.py b/src/sentry/integrations/gitlab/integration.py index e860ed5dbc99..6206643f67db 100644 --- a/src/sentry/integrations/gitlab/integration.py +++ b/src/sentry/integrations/gitlab/integration.py @@ -820,7 +820,7 @@ def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: }, } - def setup(self): + def setup(self) -> None: from sentry.plugins.base import bindings bindings.add( diff --git a/src/sentry/integrations/gitlab/issues.py b/src/sentry/integrations/gitlab/issues.py index db8dc6faf5fc..daa7d2052018 100644 --- a/src/sentry/integrations/gitlab/issues.py +++ b/src/sentry/integrations/gitlab/issues.py @@ -2,7 +2,7 @@ import re from collections.abc import Mapping, Sequence -from typing import Any +from typing import cast from django.urls import reverse @@ -27,7 +27,7 @@ class GitlabIssuesSpec(SourceCodeIssueIntegration): - def make_external_key(self, data): + def make_external_key(self, data: Mapping[str, str]) -> str: return "{}:{}".format(self.model.metadata["domain_name"], data["key"]) def get_issue_url(self, key: str) -> str: @@ -39,7 +39,9 @@ def get_issue_url(self, key: str) -> str: def get_persisted_default_config_fields(self) -> Sequence[str]: return ["project"] - def get_projects_and_default(self, group: Group | None, params: Mapping[str, Any], **kwargs): + def get_projects_and_default( + self, group: Group | None, params: Mapping[str, object], **kwargs + ) -> tuple[str, list[tuple[str, str]]]: if group: defaults = self.get_project_defaults(group.project_id) else: @@ -55,7 +57,7 @@ def get_projects_and_default(self, group: Group | None, params: Mapping[str, Any default_project, project_choices = self.get_repository_choices(group, params_mut) return default_project, project_choices - def create_default_repo_choice(self, default_repo): + def create_default_repo_choice(self, default_repo: str) -> tuple[str, str]: client = self.get_client() try: # default_repo should be the project_id @@ -66,11 +68,12 @@ def create_default_repo_choice(self, default_repo): @all_silo_function def get_create_issue_config( - self, group: Group | None, user: User | RpcUser, **kwargs + self, group: Group | None, user: User | RpcUser, **kwargs: object ) -> list[IntegrationIssueConfigField]: kwargs["link_referrer"] = "gitlab_integration" fields = super().get_create_issue_config(group, user, **kwargs) - params = kwargs.pop("params", {}) + params_obj: object = kwargs.pop("params", dict[str, object]()) + params: Mapping[str, object] = cast(Mapping[str, object], params_obj) default_project, project_choices = self.get_projects_and_default(group, params, **kwargs) org = self.organization @@ -141,7 +144,7 @@ def after_link_issue(self, external_issue, **kwargs): except ApiError as e: raise IntegrationError(self.message_from_error(e)) - def get_link_issue_config(self, group: Group, **kwargs) -> list[dict[str, Any]]: + def get_link_issue_config(self, group: Group, **kwargs) -> list[IntegrationIssueConfigField]: params = kwargs.pop("params", {}) default_project, project_choices = self.get_projects_and_default(group, params, **kwargs) diff --git a/src/sentry/integrations/gitlab/repository.py b/src/sentry/integrations/gitlab/repository.py index d6ffcf1f0816..1115099a92fa 100644 --- a/src/sentry/integrations/gitlab/repository.py +++ b/src/sentry/integrations/gitlab/repository.py @@ -1,36 +1,84 @@ from __future__ import annotations import logging -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any +from collections.abc import Mapping, MutableMapping, Sequence +from datetime import datetime +from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast from sentry.integrations.services.repository.model import RpcRepository from sentry.integrations.services.repository.service import repository_service from sentry.integrations.types import IntegrationProviderSlug +from sentry.models.organization import Organization +from sentry.models.pullrequest import PullRequest +from sentry.models.repository import Repository from sentry.organizations.services.organization.model import RpcOrganization from sentry.plugins.providers import IntegrationRepositoryProvider -from sentry.plugins.providers.integration_repository import RepositoryConfig +from sentry.plugins.providers.integration_repository import CommitPatchFile, RepositoryConfig from sentry.shared_integrations.exceptions import ApiError if TYPE_CHECKING: + from sentry.integrations.gitlab.client import GitLabApiClient from sentry.integrations.gitlab.integration import GitlabIntegration # NOQA logger = logging.getLogger("sentry.integrations.gitlab") +class GitLabProject(TypedDict): + id: str | int + path_with_namespace: str + name_with_namespace: str + web_url: str + + +class GitLabRepositorySetupConfig(TypedDict): + installation: int + identifier: str | int + + +class GitLabCommit(TypedDict): + id: str + author_email: str + author_name: str + title: str + created_at: str + message: NotRequired[str | None] + + +class GitLabDiff(TypedDict): + old_path: str + new_path: str + new_file: bool + deleted_file: bool + renamed_file: bool + + +class GitLabCommitData(TypedDict): + id: str + repository: str + author_email: str + author_name: str + message: str + timestamp: datetime | None + patch_set: list[CommitPatchFile] + + class GitlabRepositoryProvider(IntegrationRepositoryProvider["GitlabIntegration"]): name = "Gitlab" repo_provider = IntegrationProviderSlug.GITLAB.value - def get_repository_data(self, organization, config): - installation = self.get_installation(config.get("installation"), organization.id) + def get_repository_data( + self, organization: Organization, config: MutableMapping[str, object] + ) -> MutableMapping[str, object]: + installation = self.get_installation( + cast(GitLabRepositorySetupConfig, config).get("installation"), organization.id + ) client = installation.get_client() - repo_id = config["identifier"] - instance = installation.model.metadata["instance"] + repo_id = cast(GitLabRepositorySetupConfig, config)["identifier"] + instance = cast(str, installation.model.metadata["instance"]) try: - project = client.get_project(repo_id) + project = cast(GitLabProject, client.get_project(repo_id)) except Exception as e: raise installation.raise_error(e) config.update( @@ -125,7 +173,7 @@ def on_create_repository(self, repo: RpcRepository, organization: RpcOrganizatio event = "gitlab.repository.webhook_updated" logger.info(event, extra={**log_extra, "gitlab.repository.webhook_id": hook_id}) - def on_delete_repository(self, repo): + def on_delete_repository(self, repo: Repository) -> None: """Clean up the attached webhook""" webhook_id = repo.config.get("webhook_id") # A repository whose hook creation failed has no webhook_id. There is no hook to @@ -141,21 +189,38 @@ def on_delete_repository(self, repo): return raise installation.raise_error(e) - def compare_commits(self, repo, start_sha, end_sha): + def compare_commits( + self, repo: Repository, start_sha: str | None, end_sha: str + ) -> list[GitLabCommitData]: """Fetch the commit list and diffed files between two SHAs""" installation = self.get_installation(repo.integration_id, repo.organization_id) client = installation.get_client() try: if start_sha is None: - res = client.get_last_commits(repo.config["project_id"], end_sha) - return self._format_commits(client, repo, res) + commits = cast( + Sequence[GitLabCommit], + client.get_last_commits(repo.config["project_id"], end_sha), + ) + return self._format_commits(client, repo, commits) else: - res = client.compare_commits(repo.config["project_id"], start_sha, end_sha) - return self._format_commits(client, repo, res["commits"]) + comparison = cast( + Mapping[str, object], + client.compare_commits(repo.config["project_id"], start_sha, end_sha), + ) + return self._format_commits( + client, + repo, + cast(Sequence[GitLabCommit], comparison["commits"]), + ) except Exception as e: raise installation.raise_error(e) - def _format_commits(self, client, repo, commit_list): + def _format_commits( + self, + client: GitLabApiClient, + repo: Repository, + commit_list: Sequence[GitLabCommit], + ) -> list[GitLabCommitData]: """Convert GitLab commits into our internal format""" return [ { @@ -170,15 +235,17 @@ def _format_commits(self, client, repo, commit_list): for c in commit_list ] - def _get_patchset(self, client, repo, sha): + def _get_patchset( + self, client: GitLabApiClient, repo: Repository, sha: str + ) -> list[CommitPatchFile]: """GitLab commit lists don't come with diffs so we have to make additional round trips. """ - diffs = client.get_diff(repo.config["project_id"], sha) + diffs = cast(Sequence[GitLabDiff], client.get_diff(repo.config["project_id"], sha)) return self._transform_patchset(diffs) - def _transform_patchset(self, patch_set): - file_changes = [] + def _transform_patchset(self, patch_set: Sequence[GitLabDiff]) -> list[CommitPatchFile]: + file_changes: list[CommitPatchFile] = [] for changed_file in patch_set: if changed_file["new_file"]: file_changes.append({"path": changed_file["new_path"], "type": "A"}) @@ -192,8 +259,9 @@ def _transform_patchset(self, patch_set): return file_changes - def pull_request_url(self, repo, pull_request) -> str: + def pull_request_url(self, repo: Repository, pull_request: PullRequest) -> str: return f"{repo.url}/merge_requests/{pull_request.key}" - def repository_external_slug(self, repo): - return repo.config["project_id"] + def repository_external_slug(self, repo: Repository) -> str | None: + project_id: str | None = repo.config["project_id"] + return project_id diff --git a/src/sentry/integrations/gitlab/utils.py b/src/sentry/integrations/gitlab/utils.py index d677c44f102c..b3618ad3a1c9 100644 --- a/src/sentry/integrations/gitlab/utils.py +++ b/src/sentry/integrations/gitlab/utils.py @@ -86,7 +86,7 @@ class GitLabApiClientPath: users = "/users" @staticmethod - def build_api_url(base_url, path) -> str: + def build_api_url(base_url: str, path: str) -> str: return f"{base_url.rstrip('/')}{API_VERSION}{path}" @classmethod diff --git a/src/sentry/integrations/gitlab/webhooks.py b/src/sentry/integrations/gitlab/webhooks.py index 1851d8c72851..f3aa8e24313e 100644 --- a/src/sentry/integrations/gitlab/webhooks.py +++ b/src/sentry/integrations/gitlab/webhooks.py @@ -74,7 +74,7 @@ def __call__( ) -> None: ... -def _extract_payload_repo_info(request) -> dict[str, Any]: +def _extract_payload_repo_info(request: HttpRequest) -> dict[str, str | int]: """ Best-effort identifiers pulled from the webhook body. @@ -104,7 +104,7 @@ def _extract_payload_repo_info(request) -> dict[str, Any]: return {k: v for k, v in info.items() if v is not None} -def get_gitlab_external_id(request, extra) -> tuple[str, str] | HttpResponse: +def get_gitlab_external_id(request: HttpRequest, extra) -> tuple[str, str] | HttpResponse: token = "" try: # Munge the token to extract the integration external_id. @@ -207,7 +207,7 @@ def _get_repo_external_id(self, integration: RpcIntegration, event: Mapping[str, def get_repo( self, integration: RpcIntegration, organization: RpcOrganization, event: Mapping[str, Any] - ): + ) -> Repository | None: """ Given a webhook payload, get the associated Repository record. @@ -223,7 +223,7 @@ def get_repo( return None return repo - def update_repo_data(self, repo: Repository, event: Mapping[str, Any]): + def update_repo_data(self, repo: Repository, event: Mapping[str, Any]) -> None: """ Given a webhook payload, update stored repo data if needed. @@ -252,7 +252,7 @@ class IssuesEventWebhook(GitlabWebhook): EVENT_TYPE = IntegrationWebhookEventType.INBOUND_SYNC - def __call__(self, event: Mapping[str, Any], **kwargs): + def __call__(self, event: Mapping[str, Any], **kwargs) -> None: if not ( (organization := kwargs.get("organization")) and (integration := kwargs.get("integration")) @@ -465,7 +465,7 @@ class MergeEventWebhook(GitlabWebhook): handle_merge_request_event, ) - def __call__(self, event: Mapping[str, Any], **kwargs): + def __call__(self, event: Mapping[str, Any], **kwargs) -> None: if not ( (organization := kwargs.get("organization")) and (integration := kwargs.get("integration")) @@ -613,7 +613,7 @@ class NoteEventWebhook(GitlabWebhook): EVENT_TYPE = IntegrationWebhookEventType.ISSUE_COMMENT WEBHOOK_EVENT_PROCESSORS = (handle_merge_request_note_event,) - def __call__(self, event: Mapping[str, Any], **kwargs): + def __call__(self, event: Mapping[str, Any], **kwargs) -> None: if not ( (organization := kwargs.get("organization")) and (integration := kwargs.get("integration")) @@ -666,7 +666,7 @@ class PushEventWebhook(GitlabWebhook): EVENT_TYPE = IntegrationWebhookEventType.PUSH - def __call__(self, event: Mapping[str, Any], **kwargs): + def __call__(self, event: Mapping[str, Any], **kwargs) -> None: if not ( (organization := kwargs.get("organization")) and (integration := kwargs.get("integration")) diff --git a/src/sentry/integrations/jira/client.py b/src/sentry/integrations/jira/client.py index a521e792bbad..a5c50ddb266f 100644 --- a/src/sentry/integrations/jira/client.py +++ b/src/sentry/integrations/jira/client.py @@ -62,7 +62,7 @@ def __init__( integration: RpcIntegration | Integration, verify_ssl: bool, logging_context: Any | None = None, - ): + ) -> None: self.base_url = integration.metadata["base_url"] self.shared_secret = integration.metadata["shared_secret"] super().__init__( @@ -71,7 +71,7 @@ def __init__( logging_context=logging_context, ) - def finalize_request(self, prepared_request: PreparedRequest): + def finalize_request(self, prepared_request: PreparedRequest) -> PreparedRequest: assert prepared_request.url is not None assert prepared_request.method is not None path = prepared_request.url[len(self.base_url) :] @@ -95,19 +95,19 @@ def finalize_request(self, prepared_request: PreparedRequest): def get_cache_prefix(self) -> str: return "sentry-jira-2:" - def user_id_get_param(self): + def user_id_get_param(self) -> str: """ Jira-Cloud requires GDPR compliant API usage so we have to use accountId """ return "accountId" - def user_id_field(self): + def user_id_field(self) -> str: """ Jira-Cloud requires GDPR compliant API usage so we have to use accountId """ return "accountId" - def user_query_param(self): + def user_query_param(self) -> str: """ Jira-Cloud requires GDPR compliant API usage so we have to use query """ @@ -140,7 +140,7 @@ def get_projects_list(self): """deprecated - please use paginated projects endpoint""" return self.get_cached(self.PROJECT_URL) - def get_project_key_for_id(self, project_id) -> str: + def get_project_key_for_id(self, project_id: str) -> str: if not project_id: return "" projects = self.get_projects_list() @@ -223,9 +223,10 @@ def set_issue_property(self, issue_key, badge_num): data = {"type": "badge", "value": {"label": badge_num}} return self.put(self.PROPERTIES_URL % (issue_key, properties_key), data=data) - def get_email(self, account_id): + def get_email(self, account_id: str | None) -> str | None: user = self.get_cached(self.EMAIL_URL, params={"accountId": account_id}) - return user.get("email") + email: str | None = user.get("email") + return email def get_field_autocomplete(self, name, value): if name.startswith(CUSTOMFIELD_PREFIX): diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py index 96739e112adb..6002d52411ce 100644 --- a/src/sentry/integrations/jira/integration.py +++ b/src/sentry/integrations/jira/integration.py @@ -3,9 +3,9 @@ import logging import re import sys -from collections.abc import Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence from operator import attrgetter -from typing import Any, NamedTuple, NoReturn, TypedDict +from typing import Any, NamedTuple, NoReturn, TypedDict, cast import sentry_sdk from django.conf import settings @@ -246,7 +246,7 @@ class JiraIntegration(IssueSyncIntegration): comment_key = "sync_comments" @classproperty - def use_email_scope(cls): + def use_email_scope(cls) -> bool: return settings.JIRA_USE_EMAIL_SCOPE def get_organization_config(self) -> list[dict[str, Any]]: @@ -540,7 +540,9 @@ def update_organization_config(self, data): return audit_data or None - def _filter_active_projects(self, project_mappings: QuerySet[IntegrationExternalProject]): + def _filter_active_projects( + self, project_mappings: QuerySet[IntegrationExternalProject] + ) -> list[IntegrationExternalProject]: client = self.get_client() if features.has("organizations:jira-paginated-project-config", self.organization): project_ids = [pm.external_id for pm in project_mappings] @@ -709,13 +711,14 @@ def get_config_data(self): # Copied because the mapping dict assembled below is only for the response; the stored # value of this key is a bool. config = dict(self.org_integration.config) - project_mappings = IntegrationExternalProject.objects.filter( + project_mappings_query = IntegrationExternalProject.objects.filter( organization_integration_id=self.org_integration.id ) + project_mappings: Iterable[IntegrationExternalProject] = project_mappings_query sync_status_forward = {} try: - project_mappings = self._filter_active_projects(project_mappings) + project_mappings = self._filter_active_projects(project_mappings_query) except ApiError as e: logger.info( "jira.get-config-data.filter-active-projects-failed", @@ -737,7 +740,7 @@ def get_config_data(self): ) return config - def sync_metadata(self): + def sync_metadata(self) -> None: client = self.get_client() server_info = {} @@ -762,8 +765,12 @@ def sync_metadata(self): integration_id=self.model.id, name=name, metadata=metadata ) - def get_link_issue_config(self, group, **kwargs): - fields = super().get_link_issue_config(group, **kwargs) + def get_link_issue_config( + self, group: Group, **kwargs: object + ) -> list[IntegrationIssueConfigField]: + fields = cast( + list[IntegrationIssueConfigField], super().get_link_issue_config(group, **kwargs) + ) org = group.organization autocomplete_url = reverse("sentry-extensions-jira-search", args=[org.slug, self.model.id]) for field in fields: @@ -778,10 +785,10 @@ def get_issue_url(self, key: str) -> str: def get_persisted_default_config_fields(self) -> Sequence[str]: return ["project", "issuetype", "priority", "labels"] - def get_persisted_user_default_config_fields(self): + def get_persisted_user_default_config_fields(self) -> list[str]: return ["reporter"] - def get_persisted_ignored_fields(self): + def get_persisted_ignored_fields(self) -> list[str]: return self.org_integration.config.get(self.issues_ignored_fields_key, []) def get_feedback_issue_body(self, occurrence: IssueOccurrence) -> str: @@ -802,12 +809,13 @@ def get_feedback_issue_body(self, occurrence: IssueOccurrence) -> str: return body.rstrip("\n") # remove the last new line - def get_generic_issue_body(self, event): + def get_generic_issue_body(self, event: GroupEvent) -> str: + occurrence = cast(IssueOccurrence, event.occurrence) body = "" - important = event.occurrence.important_evidence_display + important = occurrence.important_evidence_display if important: body = f"| *{important.name}* | {truncatechars(important.value, MAX_CHAR)} |\n" - for evidence in event.occurrence.evidence_display: + for evidence in occurrence.evidence_display: if evidence.important is False: body += f"| *{evidence.name}* | {truncatechars(evidence.value, MAX_CHAR)} |\n" return body[:-2] # chop off final newline @@ -900,7 +908,7 @@ def create_comment(self, issue_id, user_id, group_note): "There was an error creating a comment on the Jira issue." ) from e - def create_comment_attribution(self, user_id, comment_text): + def create_comment_attribution(self, user_id: int, comment_text: str) -> str: user = user_service.get_user(user_id=user_id) username = "Unknown User" if user is None else user.name @@ -923,6 +931,7 @@ def update_comment(self, issue_id, user_id, group_note): ) from e def search_issues(self, query: str | None, **kwargs) -> dict[str, Any]: + query = cast(str, query) try: resp = self.get_client().search_issues(query) assert isinstance(resp, dict) @@ -986,7 +995,7 @@ def error_fields_from_json(self, data): return error_data - def search_url(self, org_slug): + def search_url(self, org_slug: str) -> str: """ Hook method that varies in Jira Server """ @@ -1139,7 +1148,9 @@ def fetch_issue_create_meta(self, client, project_id): return meta @all_silo_function - def get_create_issue_config(self, group: Group | None, user: RpcUser | User, **kwargs): + def get_create_issue_config( + self, group: Group | None, user: RpcUser | User, **kwargs + ) -> list[IntegrationIssueConfigField]: """ We use the `group` to get three things: organization_slug, project defaults, and default title and description. In the case where we're @@ -1260,7 +1271,10 @@ def get_create_issue_config(self, group: Group | None, user: RpcUser | User, **k if mb_field["label"] in params.get("ignored", []): continue mb_field["name"] = field - fields.append(mb_field) + # Builder keys (label/required/type + optional + # choices/url/multiple/default) plus the caller-set name + # exactly match IntegrationIssueConfigField. + fields.append(cast(IntegrationIssueConfigField, mb_field)) for field in fields: if field["name"] == "priority": @@ -1502,7 +1516,7 @@ def sync_status_outbound( except ApiInvalidRequestError as e: self.raise_error(e) - def _get_done_statuses(self): + def _get_done_statuses(self) -> set[str]: client = self.get_client() statuses = client.get_valid_statuses() return {s["id"] for s in statuses if s["statusCategory"]["key"] == "done"} diff --git a/src/sentry/integrations/jira_server/client.py b/src/sentry/integrations/jira_server/client.py index 306bafdbe659..b5e2a960bcb0 100644 --- a/src/sentry/integrations/jira_server/client.py +++ b/src/sentry/integrations/jira_server/client.py @@ -1,7 +1,8 @@ from __future__ import annotations import logging -from typing import Any +from collections.abc import Mapping +from typing import Any, cast from urllib.parse import parse_qsl, urlparse from django.urls import reverse @@ -63,7 +64,7 @@ def __init__( integration: RpcIntegration | Integration, identity: RpcIdentity, logging_context: Any | None = None, - ): + ) -> None: self.base_url = integration.metadata["base_url"] self.identity = identity super().__init__( @@ -78,7 +79,7 @@ def get_cache_prefix(self) -> str: def finalize_request(self, prepared_request: PreparedRequest) -> PreparedRequest: return self.authorize_request(prepared_request=prepared_request) - def authorize_request(self, prepared_request: PreparedRequest): + def authorize_request(self, prepared_request: PreparedRequest) -> PreparedRequest: """Jira Server authorizes with RSA-signed OAuth1 scheme""" if not self.identity: return prepared_request @@ -106,7 +107,7 @@ def user_query_param(self) -> str: def get_issue(self, issue_id): return self.get(self.ISSUE_URL % (issue_id,)) - def search_issues(self, query): + def search_issues(self, query: str) -> dict[str, object]: issue_key = parse_jira_issue_key(query, self.base_url) if issue_key is not None: # the key pattern cannot contain a quote, so it needs no escaping @@ -114,16 +115,27 @@ def search_issues(self, query): else: q = query.replace('"', '\\"') jql = f'text ~ "{q}"' - return self.get( - self.SEARCH_URL, - params={"jql": jql, "startAt": 0, "maxResults": 20, "fields": "summary"}, + return cast( + dict[str, object], + self.get( + self.SEARCH_URL, + params={"jql": jql, "startAt": 0, "maxResults": 20, "fields": "summary"}, + ), ) - def create_comment(self, issue_key, comment): - return self.post(self.COMMENTS_URL % issue_key, data={"body": comment}) + def create_comment(self, issue_key: int | str, comment: str) -> dict[str, object]: + return cast( + dict[str, object], + self.post(self.COMMENTS_URL % issue_key, data={"body": comment}), + ) - def update_comment(self, issue_key, comment_id, comment): - return self.put(self.COMMENT_URL % (issue_key, comment_id), data={"body": comment}) + def update_comment( + self, issue_key: int | str, comment_id: str, comment: str + ) -> dict[str, object]: + return cast( + dict[str, object], + self.put(self.COMMENT_URL % (issue_key, comment_id), data={"body": comment}), + ) def get_projects_list(self, cached: bool = True): if not cached: @@ -138,7 +150,7 @@ def get_issue_fields(self, project_id, issue_type_id): # Get a list of fields for the issue type and project return self.get_cached(self.ISSUE_FIELDS_URL % (project_id, issue_type_id)) - def get_project_key_for_id(self, project_id) -> str: + def get_project_key_for_id(self, project_id: str) -> str: if not project_id: return "" projects = self.get_projects_list() @@ -147,8 +159,8 @@ def get_project_key_for_id(self, project_id) -> str: return project["key"] return "" - def get_versions(self, project): - return self.get_cached(self.VERSIONS_URL % project) + def get_versions(self, project: str) -> list[dict[str, object]]: + return cast(list[dict[str, object]], self.get_cached(self.VERSIONS_URL % project)) def get_priorities(self): """ @@ -168,11 +180,14 @@ def get_priorities(self): """ return self.get_cached(self.PRIORITIES_URL) - def search_users_for_project(self, project, username): + def search_users_for_project(self, project: str, username: str) -> list[dict[str, object]]: # Jira Server wants a project key, while cloud is indifferent. project_key = self.get_project_key_for_id(project) - return self.get_cached( - self.USERS_URL, params={"project": project_key, self.user_query_param(): username} + return cast( + list[dict[str, object]], + self.get_cached( + self.USERS_URL, params={"project": project_key, self.user_query_param(): username} + ), ) def search_users_for_issue(self, issue_key, email): @@ -180,9 +195,12 @@ def search_users_for_issue(self, issue_key, email): self.USERS_URL, params={"issueKey": issue_key, self.user_query_param(): email} ) - def get_user(self, user_id): + def get_user(self, user_id: str) -> dict[str, object]: user_id_get_param = self.user_id_get_param() - return self.get_cached(self.USER_URL, params={user_id_get_param: user_id}) + return cast( + dict[str, object], + self.get_cached(self.USER_URL, params={user_id_get_param: user_id}), + ) def create_issue(self, raw_form_data): data = {"fields": raw_form_data} @@ -240,13 +258,15 @@ class JiraServerSetupClient(ApiClient): LEGACY_WEBHOOK_URL = "/rest/webhooks/1.0/webhook" @control_silo_function - def __init__(self, base_url, consumer_key, private_key, verify_ssl=True): + def __init__( + self, base_url: str, consumer_key: str, private_key: str, verify_ssl: bool = True + ) -> None: self.base_url = base_url self.consumer_key = consumer_key self.private_key = private_key self.verify_ssl = verify_ssl - def get_request_token(self): + def get_request_token(self) -> dict[str, str]: """ Step 1 of the oauth flow. Get a request token that we can have the user verify. @@ -255,14 +275,16 @@ def get_request_token(self): resp = self.post(url, allow_text=True) return dict(parse_qsl(resp.text)) - def get_authorize_url(self, request_token): + def get_authorize_url(self, request_token: Mapping[str, str]) -> str: """ Step 2 of the oauth flow. Get a URL that the user can verify our request token at. """ return self.authorize_url.format(self.base_url, request_token["oauth_token"]) - def get_access_token(self, request_token, verifier): + def get_access_token( + self, request_token: Mapping[str, str], verifier: str | None + ) -> dict[str, str]: """ Step 3 of the oauth flow. Use the verifier and request token from step 1 to get an access token. @@ -283,7 +305,9 @@ def get_access_token(self, request_token, verifier): resp = self.post(url, auth=auth, allow_text=True) return dict(parse_qsl(resp.text)) - def create_issue_webhook(self, external_id, secret, credentials): + def create_issue_webhook( + self, external_id: str, secret: str, credentials: Mapping[str, str] + ) -> dict[str, object]: auth = OAuth1( client_key=credentials["consumer_key"], rsa_key=credentials["private_key"], @@ -323,7 +347,7 @@ def create_issue_webhook(self, external_id, secret, credentials): # Fallback to legacy webhook endpoint if we encounter an error webhook_url = self.LEGACY_WEBHOOK_URL - return self.post(webhook_url, auth=auth, data=data) + return cast(dict[str, object], self.post(webhook_url, auth=auth, data=data)) def request(self, *args, **kwargs): """ diff --git a/src/sentry/integrations/jira_server/integration.py b/src/sentry/integrations/jira_server/integration.py index dbf63b83647d..fa15d2343bf4 100644 --- a/src/sentry/integrations/jira_server/integration.py +++ b/src/sentry/integrations/jira_server/integration.py @@ -3,7 +3,7 @@ import logging import re from collections.abc import Mapping, Sequence -from typing import Any, NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict, cast from urllib.parse import urlparse from cryptography.hazmat.backends import default_backend @@ -44,10 +44,12 @@ IntegrationPipelineViewEvent, IntegrationPipelineViewType, ) +from sentry.models.activity import Activity from sentry.models.group import Group from sentry.organizations.services.organization.service import organization_service from sentry.pipeline.types import PipelineStepResult from sentry.pipeline.views.base import ApiPipelineSteps +from sentry.services.eventstore.models import GroupEvent from sentry.shared_integrations.exceptions import ( ApiError, ApiHostError, @@ -348,7 +350,7 @@ def get_client(self): except Identity.DoesNotExist: raise IntegrationError("Identity not found.") - def get_organization_config(self): + def get_organization_config(self) -> list[_Config]: configuration: list[_Config] = [ { "name": self.outbound_status_key, @@ -555,8 +557,12 @@ def sync_metadata(self) -> None: metadata=self.model.metadata, ) - def get_link_issue_config(self, group, **kwargs): - fields = super().get_link_issue_config(group, **kwargs) + def get_link_issue_config( + self, group: Group, **kwargs: object + ) -> list[IntegrationIssueConfigField]: + fields = cast( + list[IntegrationIssueConfigField], super().get_link_issue_config(group, **kwargs) + ) org = group.organization autocomplete_url = reverse( "sentry-extensions-jiraserver-search", args=[org.slug, self.model.id] @@ -593,13 +599,15 @@ def get_issue_url(self, key: str) -> str: def get_persisted_default_config_fields(self) -> Sequence[str]: return ["project", "issuetype", "priority", "labels"] - def get_persisted_user_default_config_fields(self): + def get_persisted_user_default_config_fields(self) -> list[str]: return ["reporter"] - def get_persisted_ignored_fields(self): + def get_persisted_ignored_fields(self) -> list[str]: return self.org_integration.config.get(self.issues_ignored_fields_key, []) - def get_group_description(self, group, event, **kwargs): + def get_group_description( + self, group: Group, event: GroupEvent | None, **kwargs: object + ) -> str: output = [ "Sentry Issue: [{}|{}]".format( group.qualified_short_id, @@ -634,11 +642,13 @@ def get_issue(self, issue_id, **kwargs): "metadata": {PROVIDER_ISSUE_ID_KEY: issue.get("id")}, } - def create_comment(self, issue_id, user_id, group_note): + def create_comment( + self, issue_id: int | str, user_id: int, group_note: Activity + ) -> dict[str, object]: # https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=texteffects comment = group_note.data["text"] quoted_comment = self.create_comment_attribution(user_id, comment) - return self.get_client().create_comment(issue_id, quoted_comment) + return cast(dict[str, object], self.get_client().create_comment(issue_id, quoted_comment)) def create_comment_attribution(self, user_id: int, comment_text: str) -> str: user = user_service.get_user(user_id=user_id) @@ -646,13 +656,19 @@ def create_comment_attribution(self, user_id: int, comment_text: str) -> str: attribution = f"{user.name} wrote:\n\n" return f"{attribution}{{quote}}{comment_text}{{quote}}" - def update_comment(self, issue_id, user_id, group_note): + def update_comment( + self, issue_id: int | str, user_id: int, group_note: Activity + ) -> dict[str, object]: quoted_comment = self.create_comment_attribution(user_id, group_note.data["text"]) - return self.get_client().update_comment( - issue_id, group_note.data["external_id"], quoted_comment + return cast( + dict[str, object], + self.get_client().update_comment( + issue_id, group_note.data["external_id"], quoted_comment + ), ) - def search_issues(self, query: str | None, **kwargs) -> dict[str, Any]: + def search_issues(self, query: str | None, **kwargs: object) -> dict[str, Any]: + query = cast(str, query) try: resp = self.get_client().search_issues(query) assert isinstance(resp, dict) @@ -698,7 +714,7 @@ def error_fields_from_json(self, data): return {key: [error] for key, error in data.get("errors").items()} - def search_url(self, org_slug): + def search_url(self, org_slug: str) -> str: return reverse("sentry-extensions-jiraserver-search", args=[org_slug, self.model.id]) def build_dynamic_field(self, field_meta, group=None): @@ -946,7 +962,10 @@ def get_create_issue_config(self, group: Group | None, user: User | RpcUser, **k if mb_field["label"] in params.get("ignored", []): continue mb_field["name"] = field - fields.append(mb_field) + # Builder keys (label/required/type + optional + # choices/url/multiple/default) plus the caller-set name + # exactly match IntegrationIssueConfigField. + fields.append(cast(IntegrationIssueConfigField, mb_field)) for field in fields: if field["name"] == "priority": @@ -1362,7 +1381,7 @@ def sync_status_outbound( client.transition_issue(external_issue.key, transition["id"]) - def _get_done_statuses(self): + def _get_done_statuses(self) -> set[str]: client = self.get_client() statuses = client.get_valid_statuses() return {s["id"] for s in statuses if s["statusCategory"]["key"] == "done"} @@ -1376,7 +1395,12 @@ def get_resolve_sync_action(self, data: Mapping[str, Any]) -> ResolveSyncAction: should_unresolve=c_from in done_statuses and c_to not in done_statuses, ) - def after_link_issue(self, external_issue, data=None, **kwargs): + def after_link_issue( + self, + external_issue: ExternalIssue, + data: Mapping[str, str] | None = None, + **kwargs: object, + ) -> None: super().after_link_issue(external_issue, **kwargs) if data: @@ -1444,7 +1468,13 @@ def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: }, } - def create_webhook(self, external_id, webhook_secret, install, credentials): + def create_webhook( + self, + external_id: str, + webhook_secret: str, + install: InstallationConfigData, + credentials: Mapping[str, str], + ) -> None: client = JiraServerSetupClient( install["url"], install["consumer_key"], install["private_key"], install["verify_ssl"] ) diff --git a/src/sentry/integrations/jira_server/search.py b/src/sentry/integrations/jira_server/search.py index 8084ef9b224a..89d1d694e8c9 100644 --- a/src/sentry/integrations/jira_server/search.py +++ b/src/sentry/integrations/jira_server/search.py @@ -25,7 +25,7 @@ class JiraServerSearchEndpoint(IntegrationEndpoint): } provider = IntegrationProviderSlug.JIRA_SERVER.value - def _get_integration(self, organization, integration_id) -> Integration: + def _get_integration(self, organization: RpcOrganization, integration_id: int) -> Integration: return Integration.objects.get( organizationintegration__organization_id=organization.id, id=integration_id, diff --git a/src/sentry/integrations/mixins/issues.py b/src/sentry/integrations/mixins/issues.py index 82a3de0f8424..193252d1c3f8 100644 --- a/src/sentry/integrations/mixins/issues.py +++ b/src/sentry/integrations/mixins/issues.py @@ -149,7 +149,7 @@ def get_group_description(self, group, event, **kwargs): @all_silo_function def get_create_issue_config( - self, group: Group | None, user: User | RpcUser, **kwargs + self, group: Group | None, user: User | RpcUser, **kwargs: object ) -> list[IntegrationIssueConfigField]: """ These fields are used to render a form for the user, diff --git a/src/sentry/integrations/msteams/client.py b/src/sentry/integrations/msteams/client.py index c36621cfcd97..b6b9e832c2a7 100644 --- a/src/sentry/integrations/msteams/client.py +++ b/src/sentry/integrations/msteams/client.py @@ -3,8 +3,9 @@ import logging import time from abc import ABC +from collections.abc import Mapping from datetime import datetime, timezone -from typing import TypedDict +from typing import TypedDict, cast from urllib.parse import urlencode from requests import PreparedRequest @@ -48,18 +49,22 @@ def get_member_list(self, team_id: str, continuation_token: str | None = None): params["continuationToken"] = continuation_token return self.get(url, params=params) - def get_user_conversation_id(self, user_id: str, tenant_id: str): + def get_user_conversation_id(self, user_id: str, tenant_id: str) -> str: data = {"members": [{"id": user_id}], "channelData": {"tenant": {"id": tenant_id}}} - resp = self.post(self.CONVERSATION_URL, data=data) - return resp.get("id") + resp: dict[str, object] = self.post(self.CONVERSATION_URL, data=data) + return cast(str, resp.get("id")) - def send_message(self, conversation_id: str, data): - return self.post(self.ACTIVITY_URL % conversation_id, data=data) + def send_message(self, conversation_id: str, data: Mapping[str, object]) -> object: + response: object = self.post(self.ACTIVITY_URL % conversation_id, data=data) + return response - def update_message(self, conversation_id: str, activity_id: str, data): - return self.put(self.MESSAGE_URL % (conversation_id, activity_id), data=data) + def update_message( + self, conversation_id: str, activity_id: str, data: Mapping[str, object] + ) -> object: + response: object = self.put(self.MESSAGE_URL % (conversation_id, activity_id), data=data) + return response - def send_card(self, conversation_id: str, card): + def send_card(self, conversation_id: str, card: Mapping[str, object]) -> object: payload = { "type": "message", "attachments": [ @@ -68,7 +73,9 @@ def send_card(self, conversation_id: str, card): } return self.send_message(conversation_id, payload) - def update_card(self, conversation_id: str, activity_id: str, card): + def update_card( + self, conversation_id: str, activity_id: str, card: Mapping[str, object] + ) -> object: payload = { "type": "message", "attachments": [ @@ -83,14 +90,21 @@ def update_card(self, conversation_id: str, activity_id: str, card): class MsTeamsPreInstallClient(MsTeamsClientABC): integration_name = IntegrationProviderSlug.MSTEAMS.value - def __init__(self, access_token: str, service_url: str): + def __init__(self, access_token: str, service_url: str) -> None: super().__init__() self.access_token = access_token self.base_url = service_url.rstrip("/") - def request(self, method, path, data=None, params=None): + def request( + self, + method: str, + path: str, + data: Mapping[str, object] | None = None, + params: Mapping[str, str | int] | None = None, + ) -> object: headers = {"Authorization": f"Bearer {self.access_token}"} - return self._request(method, path, headers=headers, data=data, params=params) + response: object = self._request(method, path, headers=headers, data=data, params=params) + return response # MsTeamsClient is used with an existing integration object and handles token refreshing @@ -104,8 +118,9 @@ def request(self, *args, **kwargs): if kwargs.get("raw_response"): raise translate_msteams_api_error(error) + return None - def __init__(self, integration: Integration | RpcIntegration): + def __init__(self, integration: Integration | RpcIntegration) -> None: self.integration = integration self.metadata = self.integration.metadata self.base_url = self.metadata["service_url"].rstrip("/") @@ -114,8 +129,8 @@ def __init__(self, integration: Integration | RpcIntegration): @property def access_token(self) -> str: - access_token = self.metadata["access_token"] - expires_at = self.metadata["expires_at"] + access_token: str = self.metadata["access_token"] + expires_at: int = self.metadata["expires_at"] # We don't refresh the access token in cell silos. if SiloMode.get_current_mode() != SiloMode.CELL: @@ -156,7 +171,7 @@ class OAuthMsTeamsClient(ApiClient): TOKEN_URL = "/oauth2/v2.0/token" - def __init__(self, client_id, client_secret): + def __init__(self, client_id: str, client_secret: str) -> None: super().__init__() self.client_id = client_id self.client_secret = client_secret diff --git a/src/sentry/integrations/opsgenie/client.py b/src/sentry/integrations/opsgenie/client.py index 2471e4495cc3..ef6ca8febef0 100644 --- a/src/sentry/integrations/opsgenie/client.py +++ b/src/sentry/integrations/opsgenie/client.py @@ -56,7 +56,7 @@ def __init__(self, integration: RpcIntegration | Integration, integration_key: s def metadata(self): return self.integration.metadata - def _get_auth_headers(self): + def _get_auth_headers(self) -> dict[str, str]: return {"Authorization": f"GenieKey {self.integration_key}"} def get_alerts(self, limit: int | None = 1) -> object | None: @@ -98,7 +98,7 @@ def _get_workflow_links( def build_issue_alert_payload( self, - data, + data: Event | GroupEvent, rules: Sequence[NotificationOrigin], event: Event | GroupEvent, group: Group | None, @@ -108,7 +108,7 @@ def build_issue_alert_payload( feature_flags = format_feature_flags_detail(get_event_feature_flags(event)) feature_flags_context = {"Feature Flags": feature_flags} if feature_flags else {} - payload = { + payload: dict[str, object] = { "message": event.message or event.title, "source": "Sentry", "priority": priority, diff --git a/src/sentry/integrations/types.py b/src/sentry/integrations/types.py index f45aabce09ef..c8daf60afc98 100644 --- a/src/sentry/integrations/types.py +++ b/src/sentry/integrations/types.py @@ -1,7 +1,7 @@ from collections.abc import Sequence from dataclasses import dataclass from enum import Enum, StrEnum -from typing import Any, Generic, NotRequired, TypedDict, TypeVar +from typing import Generic, NotRequired, TypedDict, TypeVar from sentry.hybridcloud.rpc import ValueEqualityEnum @@ -12,10 +12,10 @@ class IntegrationIssueConfigField(TypedDict): name: str label: str type: str - default: NotRequired[Any] - defaultValue: NotRequired[Any] + default: NotRequired[object] + defaultValue: NotRequired[object] required: NotRequired[bool] - choices: NotRequired[Sequence[tuple[Any, str]]] + choices: NotRequired[Sequence[tuple[object, str]]] url: NotRequired[str] updatesForm: NotRequired[bool] multiple: NotRequired[bool] diff --git a/src/sentry/integrations/utils/metrics.py b/src/sentry/integrations/utils/metrics.py index bde83bf94e46..e71fd7c0d16f 100644 --- a/src/sentry/integrations/utils/metrics.py +++ b/src/sentry/integrations/utils/metrics.py @@ -244,7 +244,7 @@ def record_success(self) -> None: def record_failure( self, failure_reason: BaseException | str | None = None, - extra: dict[str, Any] | None = None, + extra: Mapping[str, object] | None = None, create_issue: bool = True, sample_log_rate: float | None = None, ) -> None: diff --git a/src/sentry/integrations/vsts/issues.py b/src/sentry/integrations/vsts/issues.py index e0630ef6cc1f..8127b617e839 100644 --- a/src/sentry/integrations/vsts/issues.py +++ b/src/sentry/integrations/vsts/issues.py @@ -161,7 +161,7 @@ def get_work_item_choices( @all_silo_function def get_create_issue_config( - self, group: Group | None, user: RpcUser | User, **kwargs: Any + self, group: Group | None, user: RpcUser | User, **kwargs: object ) -> list[IntegrationIssueConfigField]: kwargs["link_referrer"] = "vsts_integration" fields = [] diff --git a/src/sentry/interfaces/geo.py b/src/sentry/interfaces/geo.py index 970ceca6e8ff..378d9c462e46 100644 --- a/src/sentry/interfaces/geo.py +++ b/src/sentry/interfaces/geo.py @@ -1,5 +1,8 @@ __all__ = ("Geo",) +from collections.abc import Mapping +from typing import Self + from sentry.interfaces.base import Interface @@ -15,7 +18,7 @@ class Geo(Interface): """ @classmethod - def to_python(cls, data): + def to_python(cls, data: Mapping[str, str | None]) -> Self | None: data = { "country_code": data.get("country_code"), "city": data.get("city"), diff --git a/src/sentry/issues/endpoints/organization_group_index.py b/src/sentry/issues/endpoints/organization_group_index.py index 7d9c882f5a01..20164508c152 100644 --- a/src/sentry/issues/endpoints/organization_group_index.py +++ b/src/sentry/issues/endpoints/organization_group_index.py @@ -2,7 +2,7 @@ import logging from collections.abc import Mapping, Sequence from datetime import datetime, timedelta -from typing import Any +from typing import Any, cast import sentry_sdk from django.utils import timezone @@ -109,7 +109,7 @@ def inbox_search( end = max([earliest_date, end]) if start >= end: - return Paginator(Group.objects.none()).get_result() + return cast(CursorResult[Group], Paginator(Group.objects.none()).get_result()) # Make sure search terms are valid invalid_search_terms = ( @@ -160,14 +160,16 @@ def inbox_search( # We want to return groups from the endpoint, but have the cursor be related to the # GroupInbox rows. So we paginate on the GroupInbox results queryset, then fetch # the group_ids out and use them to get the actual groups. + group_ids = [cast(int, r.group_id) for r in cast(Sequence[GroupInbox], results.results)] group_qs = Group.objects.filter( - id__in=[r.group_id for r in results.results], + id__in=group_ids, project__in=projects, status=GroupStatus.UNRESOLVED, ) groups: Mapping[int, Group] = {g.id: g for g in group_qs} - results.results = [groups[r.group_id] for r in results.results if r.group_id in groups] - return results + group_results: CursorResult[Group] = cast(CursorResult[Group], results) + group_results.results = [groups[group_id] for group_id in group_ids if group_id in groups] + return group_results def search_issues( diff --git a/src/sentry/lang/javascript/processing.py b/src/sentry/lang/javascript/processing.py index 6cb01ea89650..440422560b66 100644 --- a/src/sentry/lang/javascript/processing.py +++ b/src/sentry/lang/javascript/processing.py @@ -101,7 +101,7 @@ def sourcemap_images_from_data(data): # Most people don't upload release artifacts for their third-party libraries, # so ignore missing node_modules files or chrome extensions -def should_skip_missing_source_error(abs_path) -> bool: +def should_skip_missing_source_error(abs_path: str) -> bool: return "node_modules" in abs_path or abs_path.startswith("chrome-extension:") @@ -193,11 +193,11 @@ def _handles_frame(frame, data): return True -def _is_native_frame(abs_path): +def _is_native_frame(abs_path: str) -> bool: return abs_path in ("native", "[native code]") -def _is_built_in(abs_path, platform): +def _is_built_in(abs_path: str, platform: str) -> bool: return platform == "node" and not NON_BUILTIN_PATH_REGEX.match(abs_path) diff --git a/src/sentry/lang/native/error.py b/src/sentry/lang/native/error.py index 5054c5ff4737..afae10279636 100644 --- a/src/sentry/lang/native/error.py +++ b/src/sentry/lang/native/error.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from typing import Protocol, TypedDict from sentry.lang.native.utils import image_name from sentry.models.eventerror import EventErrorType @@ -33,8 +34,24 @@ logger = logging.getLogger(__name__) +class _NativeDebugImage(Protocol): + debug_id: object + name: str | None + arch: str | None + + +class _NativeErrorEventData(TypedDict, total=False): + errors: list[object] + _metrics: dict[str, object] + + class SymbolicationFailed(Exception): - def __init__(self, message=None, type=None, obj=None): + def __init__( + self, + message: object | None = None, + type: EventErrorType | None = None, + obj: _NativeDebugImage | None = None, + ) -> None: Exception.__init__(self) self.message = str(message) self.type = type @@ -51,23 +68,23 @@ def __init__(self, message=None, type=None, obj=None): self.image_arch = None @property - def is_user_fixable(self): + def is_user_fixable(self) -> bool: """These are errors that a user can fix themselves.""" return self.type in USER_FIXABLE_ERRORS @property - def is_fatal(self): + def is_fatal(self) -> bool: """If this is true then a processing issues has to be reported.""" return self.type in FATAL_ERRORS @property - def is_sdk_failure(self): + def is_sdk_failure(self) -> bool: """An error that most likely happened because of a bad SDK.""" return self.type == EventErrorType.NATIVE_UNKNOWN_IMAGE - def get_data(self): + def get_data(self) -> dict[str, object]: """Returns the event data.""" - rv = {"message": self.message, "type": self.type} + rv: dict[str, object] = {"message": self.message, "type": self.type} if self.image_path is not None: rv["image_path"] = self.image_path if self.image_uuid is not None: @@ -88,7 +105,7 @@ def __str__(self) -> str: return "".join(rv) -def write_error(e, data): +def write_error(e: SymbolicationFailed, data: _NativeErrorEventData) -> None: if e.is_user_fixable or e.is_sdk_failure: errors = data.setdefault("errors", []) errors.append(e.get_data()) diff --git a/src/sentry/lang/native/processing.py b/src/sentry/lang/native/processing.py index 0698ee1916f1..7c3f3042f324 100644 --- a/src/sentry/lang/native/processing.py +++ b/src/sentry/lang/native/processing.py @@ -753,7 +753,7 @@ def get_native_symbolication_functions( return functions -def get_required_attachment_types(data) -> set[str]: +def get_required_attachment_types(data: Mapping[str, object]) -> set[str]: if is_minidump_event(data): return {MINIDUMP_ATTACHMENT_TYPE} elif is_applecrashreport_event(data): diff --git a/src/sentry/lang/native/sources.py b/src/sentry/lang/native/sources.py index 887216efed7e..93e3c6ae2534 100644 --- a/src/sentry/lang/native/sources.py +++ b/src/sentry/lang/native/sources.py @@ -3,8 +3,9 @@ import base64 import logging import os +from collections.abc import Iterator from copy import deepcopy -from typing import Any +from typing import Any, cast import google.auth import jsonschema @@ -307,13 +308,13 @@ def _last_upload_key(project_id: int) -> str: return f"symbols:last_upload:{project_id}" -def record_last_upload(project: Project): +def record_last_upload(project: Project) -> None: timestamp = int(deprecated_utcnow().timestamp() * 1000) _get_cluster().setex(_last_upload_key(project.id), LAST_UPLOAD_TTL, timestamp) -def get_last_upload(project_id: int): - return _get_cluster().get(_last_upload_key(project_id)) +def get_last_upload(project_id: int) -> str | None: + return cast(str | None, _get_cluster().get(_last_upload_key(project_id))) class InvalidSourcesError(Exception): @@ -340,7 +341,7 @@ def get_internal_url_prefix() -> str: return internal_url_prefix.rstrip("/") -def get_internal_source(project: Project): +def get_internal_source(project: Project) -> dict[str, str]: """ Returns the source configuration for a Sentry project. """ @@ -370,7 +371,7 @@ def get_internal_source(project: Project): } -def get_internal_artifact_lookup_source_url(project: Project): +def get_internal_artifact_lookup_source_url(project: Project) -> str: """ Returns the url used as a part of source configuration for the Sentry artifact-lookup API. """ @@ -411,7 +412,7 @@ def get_scraping_config(project: Project) -> dict[str, Any]: } -def get_internal_artifact_lookup_source(project: Project): +def get_internal_artifact_lookup_source(project: Project) -> dict[str, str]: """ Returns the source configuration for the Sentry artifact-lookup API. """ @@ -423,7 +424,7 @@ def get_internal_artifact_lookup_source(project: Project): } -def is_internal_source_id(source_id: str): +def is_internal_source_id(source_id: str) -> bool: """Determines if a DIF object source identifier is reserved for internal sentry use. This is trivial, but multiple functions in this file need to use the same definition. @@ -464,7 +465,7 @@ def normalize_user_source(source, project_id=None, event_id=None): return source -def secret_fields(source_type): +def secret_fields(source_type: str) -> Iterator[str]: """ Returns a string list of all of the fields that contain a secret in a given source. """ diff --git a/src/sentry/lang/native/utils.py b/src/sentry/lang/native/utils.py index a3cbecfcc69a..09c0892215c4 100644 --- a/src/sentry/lang/native/utils.py +++ b/src/sentry/lang/native/utils.py @@ -36,11 +36,11 @@ STORE_CRASH_REPORTS_MAX = 100 -def is_native_platform(platform): +def is_native_platform(platform: object) -> bool: return platform in NATIVE_PLATFORMS -def is_native_image(image): +def is_native_image(image: Mapping[str, object]) -> bool: return ( bool(image) and image.get("type") in NATIVE_IMAGE_TYPES @@ -71,14 +71,14 @@ def has_native_stacktraces(stacktraces: list[StacktraceInfo]) -> bool: return False -def image_name(pkg): +def image_name(pkg: str) -> str: if not pkg: return pkg split = "\\" if WINDOWS_PATH_RE.match(pkg) else "/" return pkg.rsplit(split, 1)[-1] -def get_os_from_event(event) -> str | None: +def get_os_from_event(event: Mapping[str, object]) -> str | None: """ Gets the OS name from either the OS context, or the SDK Info, which represents the runtime SDK and *NOT* the Sentry SDK. @@ -96,7 +96,7 @@ def get_os_from_event(event) -> str | None: return None -def signal_from_data(data): +def signal_from_data(data: Mapping[str, object]) -> int | None: exceptions = get_path(data, "exception", "values", filter=True) signal = get_path(exceptions, 0, "mechanism", "meta", "signal", "number") if signal is not None: @@ -141,7 +141,7 @@ def convert_crashreport_count(value: bool | None, allow_none: bool = False) -> i return int(value) -def is_minidump_event(data): +def is_minidump_event(data: Mapping[str, object]) -> bool: """ Checks whether an event indicates that it has an associated minidump. @@ -149,10 +149,10 @@ def is_minidump_event(data): ``write_minidump_placeholder``. """ exceptions = get_path(data, "exception", "values", filter=True) - return get_path(exceptions, 0, "mechanism", "type") == "minidump" + return bool(get_path(exceptions, 0, "mechanism", "type") == "minidump") -def is_applecrashreport_event(data): +def is_applecrashreport_event(data: Mapping[str, object]) -> bool: """ Checks whether an event indicates that it has an apple crash report. @@ -160,7 +160,7 @@ def is_applecrashreport_event(data): ``write_applecrashreport_placeholder``. """ exceptions = get_path(data, "exception", "values", filter=True) - return get_path(exceptions, 0, "mechanism", "type") == "applecrashreport" + return bool(get_path(exceptions, 0, "mechanism", "type") == "applecrashreport") def find_gpu_crash_dump_attachment(data: Any) -> CachedAttachment | None: @@ -212,22 +212,22 @@ class Backoff: Creates a new exponential backoff. """ - def __init__(self, initial, max): + def __init__(self, initial: float, max: float) -> None: """ :param initial: The initial backoff time in seconds. :param max: The maximum backoff time in seconds. """ self.initial = initial self.max = max - self._current = 0 + self._current: float = 0 - def reset(self): + def reset(self) -> None: """ Resets the backoff time zero. """ self._current = 0 - def sleep_failure(self): + def sleep_failure(self) -> None: """ Sleeps until the next retry attempt and increases the backoff time for the next failure. """ diff --git a/src/sentry/middleware/health.py b/src/sentry/middleware/health.py index 6947b27104db..dc79c9ce9664 100644 --- a/src/sentry/middleware/health.py +++ b/src/sentry/middleware/health.py @@ -7,7 +7,7 @@ class HealthCheck(MiddlewareMixin): - def process_request(self, request: Request): + def process_request(self, request: Request) -> HttpResponse | None: # Our health check can't be a done as a view, because we need # to bypass the ALLOWED_HOSTS check. We need to do this # since not all load balancers can send the expected Host header @@ -15,7 +15,7 @@ def process_request(self, request: Request): # Instead, we just intercept the request at this point, and return # our success/failure immediately. if request.path != "/_health/": - return + return None if "full" not in request.GET: return HttpResponse("ok", content_type="text/plain") diff --git a/src/sentry/models/artifactbundle.py b/src/sentry/models/artifactbundle.py index d71207c51978..79fa174f8779 100644 --- a/src/sentry/models/artifactbundle.py +++ b/src/sentry/models/artifactbundle.py @@ -3,7 +3,7 @@ import zipfile from collections.abc import Callable, Iterable, Mapping from enum import Enum -from typing import IO, Any +from typing import IO, Any, cast from django.db import models from django.db.models.signals import post_delete @@ -249,7 +249,7 @@ def _read_manifest(self) -> dict: return json.loads(manifest_bytes.decode("utf-8")) @staticmethod - def normalize_headers(headers: dict) -> dict: + def normalize_headers(headers: dict) -> dict[str, object]: return {k.lower(): v for k, v in headers.items()} @staticmethod @@ -270,7 +270,7 @@ def _build_memory_maps(self): continue # Building the map for debug_id lookup. headers = self.normalize_headers(info.get("headers", {})) - if (debug_id := headers.get("debug-id")) is not None: + if (debug_id := cast(str | None, headers.get("debug-id"))) is not None: debug_id = self.normalize_debug_id(debug_id) file_type = info.get("type") if ( @@ -362,7 +362,7 @@ def filter_function(_: str, info: dict) -> bool: return True headers = self.normalize_headers(info.get("headers", {})) - debug_id = self.normalize_debug_id(headers.get("debug-id", None)) + debug_id = self.normalize_debug_id(cast(str | None, headers.get("debug-id", None))) if debug_id is not None: debug_id = debug_id.lower() diff --git a/src/sentry/models/dashboard_widget.py b/src/sentry/models/dashboard_widget.py index eb45ca7cc70f..1510fec17de5 100644 --- a/src/sentry/models/dashboard_widget.py +++ b/src/sentry/models/dashboard_widget.py @@ -347,7 +347,7 @@ def can_extraction_be_auto_overridden(self): return True - def extraction_enabled(self): + def extraction_enabled(self) -> bool: """Whether on-demand is enabled or disabled for this widget. If this is enabled, Relay should be extracting metrics from events matching the associated widget_query upon ingest. """ diff --git a/src/sentry/models/files/abstractfile.py b/src/sentry/models/files/abstractfile.py index e6e2931cfaa8..750a82a8ec0b 100644 --- a/src/sentry/models/files/abstractfile.py +++ b/src/sentry/models/files/abstractfile.py @@ -8,7 +8,7 @@ import tempfile from collections.abc import Sequence from hashlib import sha1 -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import IO, TYPE_CHECKING, Any, Generic, TypeVar, cast from django.core.files.base import ContentFile from django.core.files.base import File as FileObj @@ -27,6 +27,7 @@ from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor logger = logging.getLogger(__name__) +LoggerType = logging.Logger | type[nooplogger] class ChunkedFileBlobIndexWrapper: @@ -49,11 +50,11 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, tb) -> None: self.close() - def detach_tempfile(self): + def detach_tempfile(self) -> tempfile._TemporaryFileWrapper[bytes]: if not self.prefetched: raise TypeError("Can only detech tempfiles in prefetch mode") assert self._curfile is not None - rv = self._curfile + rv = cast("tempfile._TemporaryFileWrapper[bytes]", self._curfile) self._curfile = None self.close() rv.seek(0) @@ -244,7 +245,13 @@ def _get_blobs_by_id(self, blob_ids: Sequence[int]) -> models.QuerySet[BlobType] @abc.abstractmethod def _delete_unreferenced_blob_task(self) -> Task[Any, Any]: ... - def _get_chunked_blob(self, mode=None, prefetch=False, prefetch_to=None, delete=True): + def _get_chunked_blob( + self, + mode: str | None = None, + prefetch: bool = False, + prefetch_to: str | None = None, + delete: bool = True, + ) -> ChunkedFileBlobIndexWrapper: return ChunkedFileBlobIndexWrapper( self._blob_index_records(), mode=mode, @@ -254,13 +261,13 @@ def _get_chunked_blob(self, mode=None, prefetch=False, prefetch_to=None, delete= ) @traces.trace - def getfile(self, mode=None, prefetch=False): + def getfile(self, mode: str | None = None, prefetch: bool = False) -> FileObj[bytes]: """Returns a file object. By default the file is fetched on demand but if prefetch is enabled the file is fully prefetched into a tempfile before reading can happen. """ impl = self._get_chunked_blob(mode, prefetch) - return FileObj(impl, self.name) + return FileObj(cast(IO[bytes], impl), self.name) @traces.trace def save_to(self, path) -> None: @@ -299,7 +306,13 @@ def save_to(self, path) -> None: pass @traces.trace - def putfile(self, fileobj, blob_size=DEFAULT_BLOB_SIZE, commit=True, logger=nooplogger): + def putfile( + self, + fileobj: IO[bytes], + blob_size: int = DEFAULT_BLOB_SIZE, + commit: bool = True, + logger: LoggerType = nooplogger, + ) -> list[BlobIndexType]: """ Save a fileobj into a number of chunks. diff --git a/src/sentry/models/organization.py b/src/sentry/models/organization.py index 024807594ef9..8c286e71d88a 100644 --- a/src/sentry/models/organization.py +++ b/src/sentry/models/organization.py @@ -311,12 +311,13 @@ def is_default(self): return self == type(self).get_default() - def has_access(self, user: RpcUser, access=None): + def has_access(self, user: RpcUser, access: int | None = None) -> bool: queryset = self.member_set.filter(user_id=user.id) if access is not None: queryset = queryset.filter(type__lte=access) - return queryset.exists() + exists: bool = queryset.exists() + return exists def get_audit_log_data(self): return { diff --git a/src/sentry/models/organizationmember.py b/src/sentry/models/organizationmember.py index 4c652288e6fb..8ba9d01e886a 100644 --- a/src/sentry/models/organizationmember.py +++ b/src/sentry/models/organizationmember.py @@ -4,7 +4,7 @@ import logging import secrets from collections import defaultdict -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from datetime import timedelta from enum import Enum from hashlib import md5 @@ -14,9 +14,12 @@ from django.conf import settings from django.db import models, router, transaction from django.db.models import Q, QuerySet +from django.db.models.base import ModelBase +from django.db.models.expressions import Combinable from django.urls import reverse from django.utils import timezone from django.utils.encoding import force_bytes +from django.utils.functional import Promise from django.utils.translation import gettext_lazy as _ from structlog import get_logger @@ -53,8 +56,14 @@ from sentry.utils.http import absolute_uri if TYPE_CHECKING: + from sentry.auth.provider import Provider + from sentry.auth.services.auth.model import RpcAuthProvider from sentry.integrations.services.integration import RpcIntegration + from sentry.models.apikey import ApiKey from sentry.models.organization import Organization + from sentry.models.organizationmemberteam import OrganizationMemberTeam + from sentry.models.team import Team + from sentry.users.models.user import User _OrganizationMemberFlags = TypedDict( "_OrganizationMemberFlags", @@ -68,6 +77,12 @@ }, ) + +class _OrganizationMemberTeamRole(TypedDict): + team: int + role: str | None + + logger = logging.getLogger("sentry.org_roles") @@ -80,7 +95,7 @@ class InviteStatus(Enum): REQUESTED_TO_JOIN = 2 @classmethod - def as_choices(cls): + def as_choices(cls) -> tuple[tuple[int, str | Promise], ...]: return ( (InviteStatus.APPROVED.value, _("Approved")), ( @@ -103,7 +118,7 @@ def as_choices(cls): class OrganizationMemberManager(BaseManager["OrganizationMember"]): - def get_contactable_members_for_org(self, organization_id: int) -> QuerySet: + def get_contactable_members_for_org(self, organization_id: int) -> QuerySet[OrganizationMember]: """Get a list of members we can contact for an organization through email.""" # TODO(Steve): check member-limit:restricted return self.filter( @@ -156,7 +171,7 @@ def get_for_integration( "organization" ) - def get_member_invite_query(self, id: int) -> QuerySet: + def get_member_invite_query(self, id: int) -> QuerySet[OrganizationMember]: return self.filter( invite_status__in=[ InviteStatus.REQUESTED_TO_BE_INVITED.value, @@ -174,7 +189,7 @@ def get_teams_by_user(self, organization: Organization) -> dict[int, list[int]]: user_teams[user_id].append(team_id) return user_teams - def get_members_by_email_and_role(self, email: str, role: str) -> QuerySet: + def get_members_by_email_and_role(self, email: str, role: str) -> QuerySet[OrganizationMember]: users_by_email = user_service.get_many( filter=dict( emails=[email], @@ -205,12 +220,16 @@ class OrganizationMember(ReplicatedCellModel): user_id = HybridCloudForeignKey("sentry.User", on_delete="CASCADE", null=True, blank=True) # This email indicates the invite state of this membership -- it will be cleared when the user is set. # it does not necessarily represent the final email of the user associated with the membership, see user_email. - email = models.EmailField(null=True, blank=True, max_length=75) + email: models.EmailField[str | Combinable | None, str | None] = models.EmailField( + null=True, blank=True, max_length=75 + ) role = models.CharField(max_length=32, default=str(organization_roles.get_default().id)) flags = typed_dict_bitfield(_OrganizationMemberFlags, default=0) - token = models.CharField(max_length=64, null=True, blank=True, unique=True) + token: models.Field[str | int | Combinable | None, str | None] = models.CharField( + max_length=64, null=True, blank=True, unique=True + ) date_added = models.DateTimeField(default=timezone.now) token_expires_at = models.DateTimeField(default=None, null=True) has_global_access = models.BooleanField(default=True) @@ -246,7 +265,13 @@ class Meta: __repr__ = sane_repr("organization_id", "user_id", "email", "role") - def save(self, *args, **kwargs): + def save( + self, + force_insert: bool | tuple[ModelBase, ...] = False, + force_update: bool = False, + using: str | None = None, + update_fields: Iterable[str] | None = None, + ) -> None: if self.id is not None: invite = OrganizationMemberInvite.objects.filter(organization_member_id=self.id).first() assert invite is None, "Cannot save placeholder organization member for an invited user" @@ -254,60 +279,70 @@ def save(self, *args, **kwargs): with outbox_context(transaction.atomic(using=router.db_for_write(OrganizationMember))): if self.token and not self.token_expires_at: self.refresh_expires_at() - super().save(*args, **kwargs) + super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) - def refresh_from_db(self, *args, **kwargs): - super().refresh_from_db(*args, **kwargs) + def refresh_from_db( + self, + using: str | None = None, + fields: Iterable[str] | None = None, + from_queryset: QuerySet[OrganizationMember] | None = None, + ) -> None: + super().refresh_from_db(using=using, fields=fields, from_queryset=from_queryset) - def set_user(self, user_id: int): + def set_user(self, user_id: int) -> None: self.user_id = user_id self.email = None self.token = None self.token_expires_at = None - def remove_user(self): + def remove_user(self) -> None: self.email = self.get_email() self.user_id = None self.token = self.generate_token() - def regenerate_token(self): + def regenerate_token(self) -> None: self.token = self.generate_token() self.refresh_expires_at() def payload_for_update(self) -> dict[str, Any] | None: return dict(user_id=self.user_id) - def refresh_expires_at(self): + def refresh_expires_at(self) -> None: now = timezone.now() self.token_expires_at = now + timedelta(days=INVITE_DAYS_VALID) - def approve_invite(self): + def approve_invite(self) -> None: self.invite_status = InviteStatus.APPROVED.value self.regenerate_token() - def get_invite_status_name(self): + def get_invite_status_name(self) -> str | None: if self.invite_status is None: - return + return None return invite_status_names[self.invite_status] @property - def invite_approved(self): + def invite_approved(self) -> bool: return self.invite_status == InviteStatus.APPROVED.value @property - def requested_to_join(self): + def requested_to_join(self) -> bool: return self.invite_status == InviteStatus.REQUESTED_TO_JOIN.value @property - def requested_to_be_invited(self): + def requested_to_be_invited(self) -> bool: return self.invite_status == InviteStatus.REQUESTED_TO_BE_INVITED.value @property - def is_pending(self): + def is_pending(self) -> bool: return self.user_id is None @property - def token_expired(self): + def token_expired(self) -> bool: # Old tokens don't expire to preserve compatibility and not require # a backfill migration. if self.token_expires_at is None: @@ -317,7 +352,7 @@ def token_expired(self): return True @property - def legacy_token(self): + def legacy_token(self) -> str: email = self.get_email() if not email: return "" @@ -327,10 +362,10 @@ def legacy_token(self): checksum.update(force_bytes(settings.SECRET_KEY)) return checksum.hexdigest() - def generate_token(self): + def generate_token(self) -> str: return secrets.token_hex(nbytes=32) - def get_invite_link(self, referrer: str | None = None): + def get_invite_link(self, referrer: str | None = None) -> str | None: if not self.is_pending or not self.invite_approved: return None path = reverse( @@ -346,7 +381,7 @@ def get_invite_link(self, referrer: str | None = None): invite_link += "?referrer=" + referrer return invite_link - def send_invite_email(self, referrer: str | None = None): + def send_invite_email(self, referrer: str | None = None) -> None: from sentry.utils.email import MessageBuilder from sentry.utils.email.sanitize import sanitize_outbound_name @@ -370,7 +405,9 @@ def send_invite_email(self, referrer: str | None = None): mail_logger = get_logger(name="sentry.mail") mail_logger.exception(e) - def send_sso_link_email(self, sending_user_email: str, provider): + def send_sso_link_email( + self, sending_user_email: str, provider: Provider | RpcAuthProvider + ) -> None: from sentry.utils.email import MessageBuilder from sentry.utils.email.sanitize import sanitize_outbound_name @@ -391,7 +428,7 @@ def send_sso_link_email(self, sending_user_email: str, provider): ) msg.send_async([self.get_email()]) - def send_sso_unlink_email(self, disabling_user: RpcUser | str, provider): + def send_sso_unlink_email(self, disabling_user: RpcUser | str, provider: Provider) -> None: from sentry.users.services.lost_password_hash import lost_password_hash_service from sentry.utils.email import MessageBuilder from sentry.utils.email.sanitize import sanitize_outbound_name @@ -435,21 +472,21 @@ def send_sso_unlink_email(self, disabling_user: RpcUser | str, provider): ) msg.send_async([email]) - def get_display_name(self): + def get_display_name(self) -> str | None: if self.user_id: user = user_service.get_user(user_id=self.user_id) if user: return user.get_display_name() return self.email - def get_label(self): + def get_label(self) -> str | int | None: if self.user_id: user = user_service.get_user(user_id=self.user_id) if user: return user.get_label() return self.email or self.id - def get_email(self): + def get_email(self) -> str: if self.user_id: if self.user_email: return self.user_email @@ -463,14 +500,16 @@ def get_email(self): return user.email return self.email or "" - def get_avatar_type(self): + def get_avatar_type(self) -> str: if self.user_id: user = user_service.get_user(user_id=self.user_id) if user: return user.get_avatar_type() return "letter_avatar" - def get_audit_log_data(self): + def get_audit_log_data( + self, + ) -> dict[str, str | int | bool | list[int] | list[str] | None]: from sentry.models.organizationmemberteam import OrganizationMemberTeam from sentry.models.team import Team @@ -494,7 +533,7 @@ def get_audit_log_data(self): ), } - def get_teams(self): + def get_teams(self) -> QuerySet[Team]: from sentry.models.organizationmemberteam import OrganizationMemberTeam from sentry.models.team import Team @@ -505,7 +544,9 @@ def get_teams(self): ).values("team"), ) - def get_team_roles(self): + def get_team_roles( + self, + ) -> QuerySet[OrganizationMemberTeam, _OrganizationMemberTeamRole]: from sentry.models.organizationmemberteam import OrganizationMemberTeam return OrganizationMemberTeam.objects.filter( @@ -519,7 +560,9 @@ def get_scopes(self) -> frozenset[str]: return frozenset(scopes) - def validate_invitation(self, user_to_approve, allowed_roles): + def validate_invitation( + self, user_to_approve: User | RpcUser, allowed_roles: Iterable[OrganizationRole] + ) -> bool: """ Validates whether an org has the options to invite members, handle join requests, and that the member role doesn't exceed the allowed roles to invite. @@ -542,8 +585,12 @@ def validate_invitation(self, user_to_approve, allowed_roles): return True def approve_member_invitation( - self, user_to_approve, api_key=None, ip_address=None, referrer=None - ): + self, + user_to_approve: User | RpcUser, + api_key: ApiKey | None = None, + ip_address: str | None = None, + referrer: str | None = None, + ) -> None: """ Approve a member invite/join request and send an audit log entry """ @@ -579,10 +626,10 @@ def approve_member_invitation( def reject_member_invitation( self, - user_to_approve, - api_key=None, - ip_address=None, - ): + user_to_approve: User | RpcUser, + api_key: ApiKey | None = None, + ip_address: str | None = None, + ) -> None: """ Reject a member invite/join request and send an audit log entry """ diff --git a/src/sentry/models/orgauthtoken.py b/src/sentry/models/orgauthtoken.py index 2208c71bb682..558592c24590 100644 --- a/src/sentry/models/orgauthtoken.py +++ b/src/sentry/models/orgauthtoken.py @@ -40,7 +40,7 @@ ORG_AUTH_TOKEN_FORBIDDEN_SCOPES = frozenset({"org:tokens"}) -def validate_scope_list(value): +def validate_scope_list(value: list[str]) -> None: for choice in value: if choice not in SENTRY_SCOPES: raise ValidationError(f"{choice} is not a valid scope.") @@ -86,7 +86,7 @@ def get_audit_log_data(self): def get_allowed_origins(self) -> list[str]: return [] - def get_scopes(self): + def get_scopes(self) -> list[str]: return self.scope_list def has_scope(self, scope): diff --git a/src/sentry/models/projectkey.py b/src/sentry/models/projectkey.py index a8b123cec29a..0a625b46b2f2 100644 --- a/src/sentry/models/projectkey.py +++ b/src/sentry/models/projectkey.py @@ -4,7 +4,7 @@ import re import secrets from collections.abc import Mapping -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from urllib.parse import urlparse import petname @@ -33,6 +33,9 @@ from sentry.silo.base import SiloMode from sentry.tasks.relay import schedule_invalidate_project_config +if TYPE_CHECKING: + from sentry.models.project import Project + _token_re = re.compile(r"^[a-f0-9]{32}$") # TODO(dcramer): pull in enum library @@ -49,7 +52,7 @@ def post_save(self, *, instance: ProjectKey, created: bool, **kwargs: object) -> public_key=instance.public_key, trigger="projectkey.post_save" ) - def post_delete(self, instance, **kwargs): + def post_delete(self, instance: ProjectKey, **kwargs: object) -> None: schedule_invalidate_project_config( public_key=instance.public_key, trigger="projectkey.post_delete" ) @@ -152,11 +155,11 @@ def __str__(self) -> str: return str(self.public_key) @classmethod - def generate_api_key(cls): + def generate_api_key(cls) -> str: return secrets.token_hex(nbytes=16) @classmethod - def looks_like_api_key(cls, key): + def looks_like_api_key(cls, key: str) -> bool: return bool(_token_re.match(key)) @classmethod @@ -175,7 +178,7 @@ def from_dsn(cls, dsn): return ProjectKey.objects.get(public_key=public_key, project=project_id) @classmethod - def get_default(cls, project): + def get_default(cls, project: Project | int) -> ProjectKey | None: return cls.objects.filter( project=project, roles=models.F("roles").bitor(cls.roles.store), @@ -187,7 +190,7 @@ def is_active(self): return self.status == ProjectKeyStatus.ACTIVE @property - def rate_limit(self): + def rate_limit(self) -> tuple[int, int]: if self.rate_limit_count and self.rate_limit_window: return (self.rate_limit_count, self.rate_limit_window) return (0, 0) @@ -220,7 +223,7 @@ def handle_async_replication(self, shard_identifier: int) -> None: ), ) - def save(self, *args, **kwargs): + def save(self, *args: object, **kwargs: object) -> None: if not self.public_key: self.public_key = ProjectKey.generate_api_key() if not self.secret_key: @@ -229,7 +232,7 @@ def save(self, *args, **kwargs): self.label = petname.generate(2, " ", letters=10).title() super().save(*args, **kwargs) - def get_dsn(self, domain=None, secure=True, public=False): + def get_dsn(self, domain: str | None = None, secure: bool = True, public: bool = False) -> str: urlparts = urlparse(self.get_endpoint()) if not public: @@ -253,45 +256,45 @@ def organization(self): return self.project.organization @property - def dsn_private(self): + def dsn_private(self) -> str: return self.get_dsn(public=False) @property - def dsn_public(self): + def dsn_public(self) -> str: return self.get_dsn(public=True) @property - def csp_endpoint(self): + def csp_endpoint(self) -> str: endpoint = self.get_endpoint() return f"{endpoint}/api/{self.project_id}/csp-report/?sentry_key={self.public_key}" @property - def security_endpoint(self): + def security_endpoint(self) -> str: endpoint = self.get_endpoint() return f"{endpoint}/api/{self.project_id}/security/?sentry_key={self.public_key}" @property - def nel_endpoint(self): + def nel_endpoint(self) -> str: endpoint = self.get_endpoint() return f"{endpoint}/api/{self.project_id}/nel/?sentry_key={self.public_key}" @property - def minidump_endpoint(self): + def minidump_endpoint(self) -> str: endpoint = self.get_endpoint() return f"{endpoint}/api/{self.project_id}/minidump/?sentry_key={self.public_key}" @property - def playstation_endpoint(self): + def playstation_endpoint(self) -> str: endpoint = self.get_endpoint() return f"{endpoint}/api/{self.project_id}/playstation/?sentry_key={self.public_key}" @property - def integration_endpoint(self): + def integration_endpoint(self) -> str: endpoint = self.get_endpoint() return f"{endpoint}/api/{self.project_id}/integration/" @@ -299,11 +302,11 @@ def build_integration_endpoint(self, integration_name: str, postfix: str = "") - return f"{self.integration_endpoint}{integration_name}/{postfix}" @property - def otlp_traces_endpoint(self): + def otlp_traces_endpoint(self) -> str: return self.build_integration_endpoint("otlp", "v1/traces") @property - def otlp_logs_endpoint(self): + def otlp_logs_endpoint(self) -> str: return self.build_integration_endpoint("otlp", "v1/logs") @property @@ -368,7 +371,7 @@ def get_allowed_origins(self) -> frozenset[str]: return get_origins(self.project) - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, object]: return { "label": self.label, "public_key": self.public_key, @@ -379,7 +382,7 @@ def get_audit_log_data(self): "rate_limit_window": self.rate_limit_window, } - def get_scopes(self): + def get_scopes(self) -> tuple[str, ...]: return self.scopes def write_relocation_import( diff --git a/src/sentry/models/projectredirect.py b/src/sentry/models/projectredirect.py index 234301cb92e7..3363d2183a02 100644 --- a/src/sentry/models/projectredirect.py +++ b/src/sentry/models/projectredirect.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from django.db import models from django.utils import timezone @@ -6,6 +10,9 @@ from sentry.db.models import FlexibleForeignKey, Model, cell_silo_model from sentry.db.models.fields.slug import SentrySlugField +if TYPE_CHECKING: + from sentry.models.project import Project + @cell_silo_model class ProjectRedirect(Model): @@ -22,7 +29,7 @@ class Meta: unique_together = (("organization", "redirect_slug"),) @classmethod - def record(cls, project, historic_slug): + def record(cls, project: Project, historic_slug: str) -> None: """ Records a historic slug used for redirect purposes. Overwrites historic redirect slugs from previous projects diff --git a/src/sentry/models/release.py b/src/sentry/models/release.py index fdebd6bbdf80..370eff7d4fc0 100644 --- a/src/sentry/models/release.py +++ b/src/sentry/models/release.py @@ -72,7 +72,7 @@ class ReleaseStatus: ARCHIVED = 1 @classmethod - def from_string(cls, value): + def from_string(cls, value: str) -> int: if value == "open": return cls.OPEN elif value == "archived": @@ -81,7 +81,7 @@ def from_string(cls, value): raise ValueError(repr(value)) @classmethod - def to_string(cls, value): + def to_string(cls, value: int | None) -> str: # XXX(markus): Since the column is nullable we need to handle `null` here. # However `null | undefined` in request payloads means "don't change # status of release". This is why `from_string` does not consider @@ -145,7 +145,7 @@ def filter_by_semver_build( build: str, project_ids: Sequence[int] | None = None, negated: bool = False, - ) -> models.QuerySet: + ) -> ReleaseQuerySet: return self.get_queryset().filter_by_semver_build( organization_id, operator, @@ -159,7 +159,7 @@ def filter_by_semver( organization_id: int, semver_filter: SemverFilter, project_ids: Sequence[int] | None = None, - ) -> models.QuerySet: + ) -> ReleaseQuerySet: return self.get_queryset().filter_by_semver(organization_id, semver_filter, project_ids) def filter_by_stage( @@ -186,7 +186,7 @@ def filter_by_environment( value, project_ids, lookup=lookup, negated=negated ) - def order_by_recent(self): + def order_by_recent(self) -> ReleaseQuerySet: return self.get_queryset().order_by_recent() def _get_group_release_version( @@ -375,12 +375,12 @@ def __eq__(self, other: object) -> bool: and self._for_project_id == other._for_project_id ) - def __hash__(self): + def __hash__(self) -> int: # https://code.djangoproject.com/ticket/30333 return super().__hash__() @staticmethod - def is_valid_version(value): + def is_valid_version(value: str | None) -> bool: if value is None: return False @@ -395,10 +395,10 @@ def is_valid_version(value): ) @property - def is_semver_release(self): - return self.package is not None + def is_semver_release(self) -> bool: + return bool(self.package is not None) - def get_previous_release(self, project): + def get_previous_release(self, project: Project) -> ReleaseProject | None: """Get the release prior to this one. None if none exists""" return ( ReleaseProject.objects.filter(project=project, release__date_added__lt=self.date_added) @@ -407,7 +407,7 @@ def get_previous_release(self, project): ) @staticmethod - def is_semver_version(version): + def is_semver_version(version: str) -> bool: """ Method that checks if a version follows semantic versioning """ @@ -463,11 +463,11 @@ def semver_tuple(self) -> SemverVersion: ) @classmethod - def get_cache_key(cls, organization_id, version) -> str: + def get_cache_key(cls, organization_id: int, version: str) -> str: return f"release:3:{organization_id}:{md5_text(version).hexdigest()}" @classmethod - def get_lock_key(cls, organization_id, release_id) -> str: + def get_lock_key(cls, organization_id: int, release_id: int) -> str: return f"releasecommits:{organization_id}:{release_id}" @classmethod @@ -784,7 +784,7 @@ def safe_delete(self): releasefile.delete() self.delete() - def count_artifacts(self): + def count_artifacts(self) -> int: """Sum the artifact_counts of all release files. An artifact count of NULL is interpreted as 1. @@ -792,7 +792,9 @@ def count_artifacts(self): counts = get_artifact_counts([self.id]) return counts.get(self.id, 0) - def count_artifacts_in_artifact_bundles(self, project_ids: Sequence[int]): + def count_artifacts_in_artifact_bundles( + self, project_ids: Sequence[int] + ) -> tuple[str, object] | None: """ Counts the number of artifacts in the artifact bundles associated with this release and a set of projects. """ diff --git a/src/sentry/models/releasefile.py b/src/sentry/models/releasefile.py index fc0595d6eac2..54317b73ecd7 100644 --- a/src/sentry/models/releasefile.py +++ b/src/sentry/models/releasefile.py @@ -3,13 +3,17 @@ import logging import zipfile from contextlib import contextmanager +from datetime import datetime from hashlib import sha1 from io import BytesIO from tempfile import TemporaryDirectory -from typing import IO, ClassVar, Self +from types import TracebackType +from typing import IO, ClassVar, Iterable, Iterator, Mapping, Self, cast from urllib.parse import urlunsplit from django.db import models, router +from django.db.models import QuerySet +from django.db.models.base import ModelBase from django.db.models.functions import Now from django.utils import timezone from sentry_sdk import traces @@ -52,7 +56,7 @@ class PublicReleaseFileManager(models.Manager["ReleaseFile"]): """ - def get_queryset(self): + def get_queryset(self) -> QuerySet[ReleaseFile]: return super().get_queryset().select_related("file").filter(file__type="release.file") @@ -95,7 +99,13 @@ class Meta: app_label = "sentry" db_table = "sentry_releasefile" - def save(self, *args, **kwargs): + def save( + self, + force_insert: bool | tuple[ModelBase, ...] = False, + force_update: bool = False, + using: str | None = None, + update_fields: Iterable[str] | None = None, + ) -> None: from sentry.models.distribution import Distribution if not self.ident and self.name: @@ -103,29 +113,41 @@ def save(self, *args, **kwargs): if self.dist_id: dist = Distribution.objects.get(pk=self.dist_id).name self.ident = type(self).get_ident(self.name, dist) - return super().save(*args, **kwargs) + return super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) - def update(self, *args, **kwargs): + def update( + self, + using: str | None = None, + *args: str, + **kwargs: File | int | str | datetime | None, + ) -> int: # If our name is changing, we must also change the ident if "name" in kwargs and "ident" not in kwargs: dist_name = None - dist_id = kwargs.get("dist_id") or self.dist_id + dist_id = cast(int | None, kwargs.get("dist_id") or self.dist_id) if dist_id: dist_name = Distribution.objects.filter(pk=dist_id).values_list("name", flat=True)[ 0 ] - kwargs["ident"] = self.ident = type(self).get_ident(kwargs["name"], dist_name) + kwargs["ident"] = self.ident = type(self).get_ident( + cast(str, kwargs["name"]), dist_name + ) kwargs["date_accessed"] = timezone.now() - return super().update(*args, **kwargs) + return super().update(using, *args, **kwargs) @classmethod - def get_ident(cls, name, dist=None): + def get_ident(cls, name: str, dist: str | None = None) -> str: if dist is not None: return sha1_text(name + "\x00\x00" + dist).hexdigest() return sha1_text(name).hexdigest() @classmethod - def normalize(cls, url): + def normalize(cls, url: str) -> list[str]: """Transforms a full absolute url into 2 or 4 generalized options * the original url as input @@ -153,21 +175,28 @@ def normalize(cls, url): class ReleaseArchive: """Read-only view of uploaded ZIP-archive of release files""" - def __init__(self, fileobj: IO): + def __init__(self, fileobj: IO[bytes]) -> None: self._fileobj = fileobj self._zip_file = zipfile.ZipFile(self._fileobj) self.manifest = self._read_manifest() - self.artifact_count = len(self.manifest.get("files", {})) - files = self.manifest.get("files", {}) - self._entries_by_url = {entry["url"]: (path, entry) for path, entry in files.items()} + self.artifact_count = len(cast(dict[str, object], self.manifest.get("files", {}))) + files = cast(dict[str, dict[str, object]], self.manifest.get("files", {})) + self._entries_by_url = { + cast(str, entry["url"]): (path, entry) for path, entry in files.items() + } - def __enter__(self): + def __enter__(self) -> Self: return self - def __exit__(self, exc, value, tb): + def __exit__( + self, + exc: type[BaseException] | None, + value: BaseException | None, + tb: TracebackType | None, + ) -> None: self.close() - def close(self): + def close(self) -> None: self._zip_file.close() self._fileobj.close() @@ -177,11 +206,11 @@ def info(self, filename: str) -> zipfile.ZipInfo: def read(self, filename: str) -> bytes: return self._zip_file.read(filename) - def _read_manifest(self) -> dict: + def _read_manifest(self) -> dict[str, object]: manifest_bytes = self.read("manifest.json") - return json.loads(manifest_bytes.decode("utf-8")) + return cast(dict[str, object], json.loads(manifest_bytes.decode("utf-8"))) - def get_file_by_url(self, url: str) -> tuple[IO[bytes], dict]: + def get_file_by_url(self, url: str) -> tuple[IO[bytes], dict[str, object]]: """Return file-like object and headers. The caller is responsible for closing the returned stream. @@ -189,9 +218,9 @@ def get_file_by_url(self, url: str) -> tuple[IO[bytes], dict]: May raise ``KeyError`` """ filename, entry = self._entries_by_url[url] - return self._zip_file.open(filename), entry.get("headers", {}) + return self._zip_file.open(filename), cast(dict[str, object], entry.get("headers", {})) - def extract(self) -> TemporaryDirectory: + def extract(self) -> TemporaryDirectory[str]: """Extract contents to a temporary directory. The caller is responsible for cleanup of the temporary files. @@ -205,29 +234,33 @@ def extract(self) -> TemporaryDirectory: class _ArtifactIndexData: """Holds data of artifact index and keeps track of changes""" - def __init__(self, data: dict, fresh=False): + def __init__(self, data: dict[str, object], fresh: bool = False) -> None: self._data = data self.changed = fresh @property - def data(self): + def data(self) -> dict[str, object]: """Meant to be read-only""" return self._data @property - def num_files(self): - return len(self._data.get("files", {})) + def num_files(self) -> int: + files = cast(dict[str, object], self._data.get("files", dict[str, object]())) + return len(files) - def get(self, filename: str): - return self._data.get("files", {}).get(filename, None) + def get(self, filename: str) -> object: + files = cast(dict[str, object], self._data.get("files", dict[str, object]())) + return files.get(filename, None) - def update_files(self, files: dict): + def update_files(self, files: Mapping[str, object]) -> None: if files: - self._data.setdefault("files", {}).update(files) + files_map = cast(dict[str, object], self._data.setdefault("files", dict[str, object]())) + files_map.update(files) self.changed = True def delete(self, filename: str) -> bool: - result = self._data.get("files", {}).pop(filename, None) + files_map = cast(dict[str, object], self._data.get("files", dict[str, object]())) + result = files_map.pop(filename, None) deleted = result is not None if deleted: self.changed = True @@ -238,13 +271,15 @@ def delete(self, filename: str) -> bool: class _ArtifactIndexGuard: """Ensures atomic write operations to the artifact index""" - def __init__(self, release: Release, dist: Distribution | None, **filter_args): + def __init__(self, release: Release, dist: Distribution | None, **filter_args) -> None: self._release = release self._dist = dist - self._ident = ReleaseFile.get_ident(ARTIFACT_INDEX_FILENAME, dist and dist.name) + self._ident = ReleaseFile.get_ident( + ARTIFACT_INDEX_FILENAME, dist.name if dist is not None else None + ) self._filter_args = filter_args # Extra constraints on artifact index release file - def readable_data(self) -> dict | None: + def readable_data(self) -> dict[str, object] | None: """Simple read, no synchronization necessary""" try: releasefile = self._releasefile_qs()[0] @@ -253,10 +288,12 @@ def readable_data(self) -> dict | None: else: fp = releasefile.file.getfile() with fp: - return json.load(fp) + return cast(dict[str, object], json.load(fp)) @contextmanager - def writable_data(self, create: bool, initial_artifact_count=None): + def writable_data( + self, create: bool, initial_artifact_count: int | None = None + ) -> Iterator[_ArtifactIndexData | None]: """Context manager for editable artifact index""" with atomic_transaction( using=( @@ -286,12 +323,13 @@ def writable_data(self, create: bool, initial_artifact_count=None): source_file = releasefile.file if source_file.type != ARTIFACT_INDEX_TYPE: raise RuntimeError("Unexpected file type for artifact index") - raw_data = json.load(source_file.getfile()) + raw_data: dict[str, object] = json.load(source_file.getfile()) index_data = _ArtifactIndexData(raw_data) yield index_data # editable reference to index if index_data is not None and index_data.changed: + releasefile = cast(ReleaseFile, releasefile) if created: target_file = releasefile.file else: @@ -304,11 +342,13 @@ def writable_data(self, create: bool, initial_artifact_count=None): artifact_count = index_data.num_files if not created: # Update and clean existing - old_file = releasefile.file + old_file: Model = releasefile.file releasefile.update(file=target_file, artifact_count=artifact_count) old_file.delete() - def _get_or_create_releasefile(self, initial_artifact_count): + def _get_or_create_releasefile( + self, initial_artifact_count: int | None + ) -> tuple[ReleaseFile, bool]: """Make sure that the release file exists""" return ReleaseFile.objects.select_for_update().get_or_create( **self._key_fields(), @@ -321,16 +361,16 @@ def _get_or_create_releasefile(self, initial_artifact_count): }, ) - def _releasefile_qs(self): + def _releasefile_qs(self) -> QuerySet[ReleaseFile]: """QuerySet for selecting artifact index""" return ReleaseFile.objects.filter(**self._key_fields(), **self._filter_args) - def _key_fields(self): + def _key_fields(self) -> dict[str, int | str | None]: """Columns needed to identify the artifact index in the db""" return dict( organization_id=self._release.organization_id, release_id=self._release.id, - dist_id=self._dist.id if self._dist else self._dist, + dist_id=cast(int, self._dist.id) if self._dist is not None else None, name=ARTIFACT_INDEX_FILENAME, ident=self._ident, ) @@ -372,13 +412,13 @@ def update_artifact_index( with ReleaseArchive(temp_file or archive_file.getfile()) as archive: manifest = archive.manifest - files = manifest.get("files", {}) + files = cast(dict[str, object], manifest.get("files", {})) if not files: return for filename, info in files.items(): - info = info.copy() - url = info.pop("url") + info = cast(dict[str, object], info).copy() + url = cast(str, info.pop("url")) info["filename"] = filename info["archive_ident"] = releasefile.ident info["date_created"] = archive_file.timestamp @@ -388,7 +428,7 @@ def update_artifact_index( guard = _ArtifactIndexGuard(release, dist) with guard.writable_data(create=True, initial_artifact_count=len(files_out)) as index_data: - index_data.update_files(files_out) + cast(_ArtifactIndexData, index_data).update_files(files_out) return releasefile diff --git a/src/sentry/net/http.py b/src/sentry/net/http.py index 074c12cd2cb0..1ab715c4dd3d 100644 --- a/src/sentry/net/http.py +++ b/src/sentry/net/http.py @@ -58,7 +58,7 @@ def host(self) -> str: return self._dns_host.rstrip(".") @host.setter - def host(self, value) -> None: + def host(self, value: str) -> None: """ Setter for the `host` property. We assume that only urllib3 uses the _dns_host attribute; httplib itself @@ -169,7 +169,7 @@ def __init__( def init_poolmanager( self, connections: int, maxsize: int, block: bool = DEFAULT_POOLBLOCK, **pool_kwargs: Any - ): + ) -> None: self._pool_connections = connections self._pool_maxsize = maxsize self._pool_block = block @@ -185,12 +185,24 @@ def init_poolmanager( class TimeoutAdapter(HTTPAdapter): - def __init__(self, *args, **kwargs): - timeout = kwargs.pop("timeout", None) - HTTPAdapter.__init__(self, *args, **kwargs) + def __init__( + self, + pool_connections: int = 10, + pool_maxsize: int = 10, + max_retries: Retry | int = 0, + pool_block: bool = False, + timeout: float | None = None, + ) -> None: + HTTPAdapter.__init__( + self, + pool_connections=pool_connections, + pool_maxsize=pool_maxsize, + max_retries=max_retries, + pool_block=pool_block, + ) if timeout is None: timeout = 10.0 - self.default_timeout = timeout + self.default_timeout: float = timeout def send(self, *args, **kwargs): if kwargs.get("timeout") is None: @@ -240,7 +252,7 @@ def __init__(self, host: str, **kwargs): self.socket_path = host super().__init__(host="localhost", **kwargs) - def _new_conn(self): + def _new_conn(self) -> socket.socket: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) # If provided, set socket level options before connecting. diff --git a/src/sentry/notifications/api/endpoints/notification_actions_details.py b/src/sentry/notifications/api/endpoints/notification_actions_details.py index d0d646e0f0ad..5731900ef6a5 100644 --- a/src/sentry/notifications/api/endpoints/notification_actions_details.py +++ b/src/sentry/notifications/api/endpoints/notification_actions_details.py @@ -53,7 +53,9 @@ class NotificationActionsDetailsEndpoint(OrganizationEndpoint): permission_classes = (NotificationActionsPermission,) - def convert_args(self, request: Request, action_id: str, *args, **kwargs): + def convert_args( + self, request: Request, action_id: str, *args: object, **kwargs: object + ) -> tuple[tuple[object, ...], dict[str, object]]: parsed_args, parsed_kwargs = super().convert_args(request, *args, **kwargs) organization = parsed_kwargs["organization"] diff --git a/src/sentry/notifications/api/endpoints/user_notification_details.py b/src/sentry/notifications/api/endpoints/user_notification_details.py index d03394343ad6..bfaf6f6fca8a 100644 --- a/src/sentry/notifications/api/endpoints/user_notification_details.py +++ b/src/sentry/notifications/api/endpoints/user_notification_details.py @@ -1,5 +1,7 @@ from collections import defaultdict +from collections.abc import Sequence +from django.contrib.auth.models import AnonymousUser from rest_framework import serializers, status from rest_framework.request import Request from rest_framework.response import Response @@ -10,7 +12,9 @@ from sentry.api.serializers import Serializer, serialize from sentry.notifications.types import UserOptionsSettingsKey from sentry.users.api.bases.user import UserEndpoint +from sentry.users.models.user import User from sentry.users.models.user_option import UserOption +from sentry.users.services.user.model import RpcUser USER_OPTION_SETTINGS = { UserOptionsSettingsKey.SELF_ACTIVITY: { @@ -25,13 +29,19 @@ class UserNotificationsSerializer(Serializer): - def get_attrs(self, item_list, user, *args, **kwargs): + def get_attrs( + self, + item_list: Sequence[User], + user: User | RpcUser | AnonymousUser, + *args: object, + **kwargs: object, + ) -> dict[User, list[UserOption]]: user_options = UserOption.objects.filter( user__in=item_list, organization_id=None, project_id=None ).select_related("user") keys_to_user_option_objects = {user_option.key: user_option for user_option in user_options} - results = defaultdict(list) + results: dict[User, list[UserOption]] = defaultdict(list) for user_option in keys_to_user_option_objects.values(): results[user_option.user].append(user_option) return results @@ -61,11 +71,11 @@ class UserNotificationDetailsEndpoint(UserEndpoint): "PUT": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, user) -> Response: + def get(self, request: Request, user: User) -> Response: serialized = serialize(user, request.user, UserNotificationsSerializer()) return Response(serialized) - def put(self, request: Request, user) -> Response: + def put(self, request: Request, user: User) -> Response: serializer = UserNotificationDetailsSerializer(data=request.data) if not serializer.is_valid(): diff --git a/src/sentry/notifications/api/endpoints/user_notification_email.py b/src/sentry/notifications/api/endpoints/user_notification_email.py index 9b76750fc27f..f8906141f7bc 100644 --- a/src/sentry/notifications/api/endpoints/user_notification_email.py +++ b/src/sentry/notifications/api/endpoints/user_notification_email.py @@ -7,6 +7,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import control_silo_endpoint from sentry.users.api.bases.user import UserEndpoint +from sentry.users.models.user import User from sentry.users.models.user_option import UserOption from sentry.users.models.useremail import UserEmail @@ -23,7 +24,7 @@ class UserNotificationEmailEndpoint(UserEndpoint): } owner = ApiOwner.NOTIFICATIONS - def get(self, request: Request, user) -> Response: + def get(self, request: Request, user: User) -> Response: """ Fetches the user's email notification settings. Returns a dictionary where the keys are the IDs of the projects @@ -35,7 +36,7 @@ def get(self, request: Request, user) -> Response: return self.respond({str(option.project_id): option.value for option in email_options}) - def put(self, request: Request, user) -> Response: + def put(self, request: Request, user: User) -> Response: """ Updates the user's email notification settings. The request data should be a dictionary where the keys are the IDs of the projects diff --git a/src/sentry/notifications/api/endpoints/user_notification_settings_options_detail.py b/src/sentry/notifications/api/endpoints/user_notification_settings_options_detail.py index e7067b0178b0..2f7aec3c1825 100644 --- a/src/sentry/notifications/api/endpoints/user_notification_settings_options_detail.py +++ b/src/sentry/notifications/api/endpoints/user_notification_settings_options_detail.py @@ -26,7 +26,7 @@ def convert_args( *args, notification_option_id: int, **kwargs, - ): + ) -> tuple[tuple[object, ...], dict[str, object]]: args, kwargs = super().convert_args(request, user_id, *args, **kwargs) user = kwargs["user"] try: diff --git a/src/sentry/notifications/api/serializers/notification_action_request.py b/src/sentry/notifications/api/serializers/notification_action_request.py index a8c9502e67e4..f5ca0a341625 100644 --- a/src/sentry/notifications/api/serializers/notification_action_request.py +++ b/src/sentry/notifications/api/serializers/notification_action_request.py @@ -19,7 +19,7 @@ from sentry.utils.strings import oxfordize_list -def format_choices_text(choices: Sequence[tuple[int, str]]): +def format_choices_text(choices: Sequence[tuple[int, str]]) -> str: choices_as_display_text = [f"'{display_text}'" for (_, display_text) in choices] return oxfordize_list(choices_as_display_text) @@ -150,7 +150,7 @@ def validate_trigger_type(self, trigger_type: str) -> int: ) return trigger_type_value - def validate_integration_and_service(self, data: NotificationActionInputData): + def validate_integration_and_service(self, data: NotificationActionInputData) -> None: if data["service_type"] not in INTEGRATION_SERVICES: return @@ -175,7 +175,7 @@ def validate_integration_and_service(self, data: NotificationActionInputData): ) self.integration = integration - def validate_sentry_app_and_service(self, data: NotificationActionInputData): + def validate_sentry_app_and_service(self, data: NotificationActionInputData) -> None: if ( data["service_type"] == ActionService.SENTRY_APP.value and data.get("sentry_app_id") is None @@ -187,7 +187,7 @@ def validate_sentry_app_and_service(self, data: NotificationActionInputData): } ) - def validate_with_registry(self, data: NotificationActionInputData): + def validate_with_registry(self, data: NotificationActionInputData) -> None: registration = NotificationAction.get_registration( trigger_type=data["trigger_type"], service_type=data["service_type"], diff --git a/src/sentry/notifications/api/serializers/notification_action_response.py b/src/sentry/notifications/api/serializers/notification_action_response.py index 71ff7b3d060f..fd2e26680ec6 100644 --- a/src/sentry/notifications/api/serializers/notification_action_response.py +++ b/src/sentry/notifications/api/serializers/notification_action_response.py @@ -11,6 +11,8 @@ NotificationAction, NotificationActionProject, ) +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.utils.serializers import manytoone_to_dict @@ -27,13 +29,23 @@ class OutgoingNotificationActionResponse(TypedDict): targetDisplay: str | None +class NotificationActionAttrs(TypedDict): + trigger_type: str + projects: list[int] + + @register(NotificationAction) class OutgoingNotificationActionSerializer(Serializer[OutgoingNotificationActionResponse]): """ Model serializer for outgoing NotificationAction API payloads """ - def get_attrs(self, item_list: Sequence[NotificationAction], user, **kwargs): + def get_attrs( + self, + item_list: Sequence[NotificationAction], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[NotificationAction, NotificationActionAttrs]: action_ids = {i.id for i in item_list} projects_by_action_id = manytoone_to_dict( NotificationActionProject.objects.filter(action_id__in=action_ids), @@ -49,7 +61,11 @@ def get_attrs(self, item_list: Sequence[NotificationAction], user, **kwargs): } def serialize( - self, obj: NotificationAction, attrs, user, **kwargs + self, + obj: NotificationAction, + attrs, + user: User | RpcUser | AnonymousUser, + **kwargs: object, ) -> OutgoingNotificationActionResponse: return { "id": obj.id, @@ -65,7 +81,7 @@ def serialize( } @classmethod - def get_example(cls, **action_kwargs): + def get_example(cls, **action_kwargs: object) -> OutgoingNotificationActionResponse: """ Create example serialized response for documentation. Any kwargs will be applied to the NotificationAction. diff --git a/src/sentry/notifications/models/notificationaction.py b/src/sentry/notifications/models/notificationaction.py index c54b87658274..939d28020268 100644 --- a/src/sentry/notifications/models/notificationaction.py +++ b/src/sentry/notifications/models/notificationaction.py @@ -121,7 +121,7 @@ def as_choices(cls) -> tuple[tuple[int, str], ...]: class ActionRegistration(metaclass=ABCMeta): - def __init__(self, action: NotificationAction): + def __init__(self, action: NotificationAction) -> None: self.action = action @abstractmethod @@ -265,7 +265,7 @@ def inner(registration: type[ActionRegistrationT]) -> type[ActionRegistrationT]: return inner @classmethod - def get_trigger_types(cls): + def get_trigger_types(cls) -> tuple[tuple[int, str], ...]: return cls._trigger_types @classmethod @@ -294,7 +294,7 @@ def get_audit_log_data(self) -> dict[str, str]: """ return {"trigger": NotificationAction.get_trigger_text(self.trigger_type)} - def fire(self, *args, **kwargs): + def fire(self, *args: object, **kwargs: object) -> None: registration = NotificationAction.get_registration( self.trigger_type, self.service_type, self.target_type ) diff --git a/src/sentry/notifications/notificationcontroller.py b/src/sentry/notifications/notificationcontroller.py index f11ed1aaba38..6a878f322348 100644 --- a/src/sentry/notifications/notificationcontroller.py +++ b/src/sentry/notifications/notificationcontroller.py @@ -154,20 +154,14 @@ def _get_query(self) -> Q: return project_settings | org_settings | user_settings | team_settings - def _filter_options( - self, - **kwargs, - ) -> list[NotificationSettingOption]: + def _filter_options(self, **kwargs) -> list[NotificationSettingOption]: return [ setting for setting in self.get_all_setting_options if all(getattr(setting, arg) == kwargs[arg] for arg in kwargs) ] - def _filter_providers( - self, - **kwargs, - ) -> list[NotificationSettingProvider]: + def _filter_providers(self, **kwargs) -> list[NotificationSettingProvider]: return [ setting for setting in self.get_all_setting_providers diff --git a/src/sentry/notifications/notifications/organization_request/abstract_invite_request.py b/src/sentry/notifications/notifications/organization_request/abstract_invite_request.py index f950d001c95f..3982e9f2c344 100644 --- a/src/sentry/notifications/notifications/organization_request/abstract_invite_request.py +++ b/src/sentry/notifications/notifications/organization_request/abstract_invite_request.py @@ -28,7 +28,7 @@ class AbstractInviteRequestNotification(OrganizationRequestNotification, abc.ABC def __init__( self, pending_member: OrganizationMember | OrganizationMemberInvite, requester: User - ): + ) -> None: super().__init__(pending_member.organization, requester) self.pending_member = pending_member diff --git a/src/sentry/notifications/notifications/strategies/role_based_recipient_strategy.py b/src/sentry/notifications/notifications/strategies/role_based_recipient_strategy.py index 2315850a7ec4..2b7818b81f4f 100644 --- a/src/sentry/notifications/notifications/strategies/role_based_recipient_strategy.py +++ b/src/sentry/notifications/notifications/strategies/role_based_recipient_strategy.py @@ -22,7 +22,7 @@ class RoleBasedRecipientStrategy(metaclass=ABCMeta): role: OrganizationRole | None = None scope: str | None = None - def __init__(self, organization: Organization): + def __init__(self, organization: Organization) -> None: self.organization = organization def get_member(self, user: RpcUser | Actor) -> OrganizationMember: diff --git a/src/sentry/notifications/platform/service.py b/src/sentry/notifications/platform/service.py index ddabeedcc988..39aa7781c5d6 100644 --- a/src/sentry/notifications/platform/service.py +++ b/src/sentry/notifications/platform/service.py @@ -68,7 +68,7 @@ class NotificationKillswitchedError(NotificationServiceError): class NotificationService[T: NotificationData]: - def __init__(self, *, data: T): + def __init__(self, *, data: T) -> None: self.data: Final[T] = data @staticmethod diff --git a/src/sentry/notifications/utils/__init__.py b/src/sentry/notifications/utils/__init__.py index 983abde5a229..7be207d22241 100644 --- a/src/sentry/notifications/utils/__init__.py +++ b/src/sentry/notifications/utils/__init__.py @@ -141,11 +141,10 @@ def process_serialized_committers( for commit in committer["commits"]: if commit["id"] not in commits: commit_data = dict(commit) - commit_data["shortId"] = commit_data["id"][:7] + commit_data["shortId"] = cast(str, commit_data["id"])[:7] commit_data["author"] = committer["author"] - commit_data["subject"] = ( - commit_data["message"].split("\n", 1)[0] if commit_data["message"] else "" - ) + commit_message = cast(str | None, commit_data["message"]) + commit_data["subject"] = commit_message.split("\n", 1)[0] if commit_message else "" if commit.get("pullRequest"): commit_data["pull_request"] = commit["pullRequest"] commits[commit["id"]] = commit_data @@ -320,7 +319,7 @@ def get_performance_issue_alert_subtitle(event: GroupEvent) -> str: def get_notification_group_title( - group: Group, event: Event | GroupEvent, max_length: int = 255, **kwargs: str + group: Group | None, event: Event | GroupEvent, max_length: int = 255, **kwargs: str ) -> str: if isinstance(event, GroupEvent) and event.occurrence is not None: issue_title = event.occurrence.issue_title diff --git a/src/sentry/notifications/validators.py b/src/sentry/notifications/validators.py index 3c1b292a66fa..86275c215ae8 100644 --- a/src/sentry/notifications/validators.py +++ b/src/sentry/notifications/validators.py @@ -1,3 +1,5 @@ +from typing import TypedDict + from rest_framework import serializers from sentry.api.exceptions import ParameterValidationError @@ -11,19 +13,27 @@ from sentry.notifications.types import NOTIFICATION_SETTING_CHOICES, NotificationScopeEnum +class UserNotificationSettingData(TypedDict, total=False): + type: str + scope_type: str + scope_identifier: str + value: str + providers: list[str] + + class UserNotificationSettingsOptionsDetailsSerializer(CamelSnakeSerializer): type = serializers.CharField() scope_identifier = serializers.CharField() scope_type = serializers.CharField() - def validate_type(self, value): + def validate_type(self, value: str) -> str: try: validate_type(value) return value except ParameterValidationError: raise serializers.ValidationError("Invalid type") - def validate_scope_type(self, value): + def validate_scope_type(self, value: str) -> str: try: validate_scope_type(value) return value @@ -36,12 +46,12 @@ class UserNotificationSettingOptionWithValueSerializer( ): value = serializers.CharField() - def validate_value(self, value): + def validate_value(self, value: str) -> str: if value not in NOTIFICATION_SETTING_CHOICES: raise serializers.ValidationError("Invalid value") return value - def validate(self, data): + def validate(self, data: UserNotificationSettingData) -> UserNotificationSettingData: try: enum_type = validate_type(data["type"]) validate_value(enum_type, data["value"]) @@ -55,13 +65,13 @@ class UserNotificationSettingsProvidersDetailsSerializer( ): providers = serializers.ListField(child=serializers.CharField()) - def validate_providers(self, value): + def validate_providers(self, value: list[str]) -> list[str]: for provider in value: if provider not in PERSONAL_NOTIFICATION_PROVIDERS: raise serializers.ValidationError("Invalid provider") return value - def validate_scope_type(self, value): + def validate_scope_type(self, value: str) -> str: # for now, we limit the scopes for provider settings if value not in [ NotificationScopeEnum.USER.value, diff --git a/src/sentry/options/manager.py b/src/sentry/options/manager.py index 0fb1225ca376..e6047c4baf27 100644 --- a/src/sentry/options/manager.py +++ b/src/sentry/options/manager.py @@ -3,7 +3,7 @@ import logging import os import sys -from collections.abc import Callable, Iterable, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence from enum import Enum from typing import TYPE_CHECKING from typing import Any as TAny @@ -239,7 +239,7 @@ def set(self, key: str, value, coerce=True, channel: UpdateChannel = UpdateChann return self.store.set(opt, value, channel=channel) - def lookup_key(self, key: str): + def lookup_key(self, key: str) -> Key: try: return self.registry[key] except KeyError: @@ -424,7 +424,7 @@ def register( if not callable(default): default_value = default - def default(): + def default() -> object: return default_value else: @@ -437,7 +437,7 @@ def default(): if default_value is None: default_value = "" - def default(): + def default() -> object: return default_value type = type_from_value(default_value) @@ -474,7 +474,7 @@ def unregister(self, key: str) -> None: # Raise here or nah? raise UnknownOption(key) - def validate(self, options, warn=False): + def validate(self, options: Mapping[str, object], warn: bool = False) -> None: for k, v in options.items(): try: self.validate_option(k, v) @@ -483,7 +483,7 @@ def validate(self, options, warn=False): raise sys.stderr.write("* Unknown config option found: %s\n" % e) - def validate_option(self, key: str, value): + def validate_option(self, key: str, value: object) -> None: opt = self.lookup_key(key) if not opt.type.test(value): raise TypeError(f"{key!r}: got {_type(value)!r}, expected {opt.type!r}") @@ -494,7 +494,7 @@ def all(self) -> Iterable[Key]: """ return self.registry.values() - def filter(self, flag: int | None = None): + def filter(self, flag: int | None = None) -> Iterable[Key]: """ Return an iterator that's filtered by which flags are set on a key. """ @@ -515,7 +515,7 @@ def get_last_update_channel(self, key: str) -> UpdateChannel | None: return self.store.get_last_update_channel(opt) def can_update( - self, key: str, value, channel: UpdateChannel, include_drift: bool = True + self, key: str, value: object, channel: UpdateChannel, include_drift: bool = True ) -> NotWritableReason | None: """ Return the reason the provided channel cannot update the option diff --git a/src/sentry/options/store.py b/src/sentry/options/store.py index 9f5eacc54d3a..7e65ef27260b 100644 --- a/src/sentry/options/store.py +++ b/src/sentry/options/store.py @@ -4,9 +4,10 @@ import logging from random import random from time import time -from typing import Any +from typing import TYPE_CHECKING, Any from django.conf import settings +from django.core.cache.backends.base import BaseCache from django.db.utils import OperationalError, ProgrammingError from django.utils import timezone from sentry_sdk.integrations.logging import ignore_logger @@ -15,6 +16,9 @@ from sentry.options.manager import UpdateChannel from sentry.utils.types import Type +if TYPE_CHECKING: + from django.utils.connection import ConnectionProxy + CACHE_FETCH_ERR = "Unable to fetch option cache for %s" CACHE_UPDATE_ERR = "Unable to update option cache for %s" @@ -58,7 +62,7 @@ def has_any_flag(self, flags: set[int]) -> bool: return False -def _make_cache_value(key, value): +def _make_cache_value(key: Key, value: object) -> tuple[object, int, int]: now = int(time()) return (value, now + key.ttl, now + key.ttl + key.grace) @@ -96,7 +100,7 @@ def model_cls(cls): return ControlOption return Option - def get(self, key, silent=False): + def get(self, key: Key, silent: bool = False) -> object | None: """ Fetches a value from the options store. """ @@ -121,13 +125,14 @@ def get(self, key, silent=False): # in local cache that's possibly stale return self.get_local_cache(key, force_grace=True) - def get_cache(self, key, silent=False): + def get_cache(self, key: Key, silent: bool = False) -> object | None: """ First check against our local in-process cache, falling back to the network cache. """ assert self.cache is not None, ( - f"Option '{key.name}' requested before cache initialization, which could result in excessive store queries" + f"Option '{key.name}' requested before cache initialization, " + "which could result in excessive store queries" ) value = self.get_local_cache(key) @@ -150,7 +155,7 @@ def get_cache(self, key, silent=False): return value - def get_local_cache(self, key, force_grace=False): + def get_local_cache(self, key: Key, force_grace: bool = False) -> object | None: """ Attempt to fetch a key out of the local cache. @@ -195,7 +200,7 @@ def get_local_cache(self, key, force_grace=False): # in grace, too bad. The value is considered bad. return None - def get_store(self, key, silent=False): + def get_store(self, key: Key, silent: bool = False) -> object | None: """ Attempt to fetch value from the database. If successful, also set it back in the cache. @@ -236,7 +241,7 @@ def get_store(self, key, silent=False): ) return value - def get_last_update_channel(self, key) -> UpdateChannel | None: + def get_last_update_channel(self, key: Key) -> UpdateChannel | None: """ Gets how the option was last updated to check for drift. """ @@ -285,7 +290,7 @@ def set_cache(self, key, value): logger.warning(CACHE_UPDATE_ERR, key.name, extra={"key": key.name}, exc_info=True) return False - def delete(self, key): + def delete(self, key: Key) -> bool: """ Remove key out of option stores. This operation must succeed on the database first. If database fails, an exception is raised. @@ -297,10 +302,10 @@ def delete(self, key): self.delete_store(key) return self.delete_cache(key) - def delete_store(self, key): + def delete_store(self, key: Key) -> None: self.model.objects.filter(key=key.name).delete() - def delete_cache(self, key): + def delete_cache(self, key: Key) -> bool: cache_key = key.cache_key try: del self._local_cache[cache_key] @@ -314,7 +319,7 @@ def delete_cache(self, key): logger.warning(CACHE_UPDATE_ERR, key.name, extra={"key": key.name}, exc_info=True) return False - def clean_local_cache(self): + def clean_local_cache(self) -> None: """ Iterate over our local cache items, and remove the keys that are beyond their grace time. @@ -342,13 +347,13 @@ def clean_local_cache(self): # but we'll guard ourselves against it Justin Case. pass - def flush_local_cache(self): + def flush_local_cache(self) -> None: """ Empty store's local in-process cache. """ - self._local_cache = {} + self._local_cache: dict[str, tuple[object, int, int]] = {} - def maybe_clean_local_cache(self, **kwargs): + def maybe_clean_local_cache(self, **kwargs: object) -> None: # Periodically force an expire on the local cache. # This cleanup is purely to keep memory low and garbage collect # old values. It's not required to run to keep things consistent. @@ -363,5 +368,5 @@ def maybe_clean_local_cache(self, **kwargs): def close(self) -> None: self.clean_local_cache() - def set_cache_impl(self, cache) -> None: + def set_cache_impl(self, cache: BaseCache | ConnectionProxy[BaseCache]) -> None: self.cache = cache diff --git a/src/sentry/plugins/providers/integration_repository.py b/src/sentry/plugins/providers/integration_repository.py index 972dea83eaeb..ac910116700a 100644 --- a/src/sentry/plugins/providers/integration_repository.py +++ b/src/sentry/plugins/providers/integration_repository.py @@ -631,7 +631,7 @@ def on_create_repository(self, repo: RpcRepository, organization: RpcOrganizatio def on_delete_repository(self, repo: Any) -> None: pass - def format_date(self, date: str | None) -> Any | None: + def format_date(self, date: str | None) -> datetime | None: if not date: return None return parse_date(date).astimezone(timezone.utc) diff --git a/src/sentry/quotas/base.py b/src/sentry/quotas/base.py index ede717c0b380..5b2adf6d126c 100644 --- a/src/sentry/quotas/base.py +++ b/src/sentry/quotas/base.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass from enum import IntEnum, StrEnum, unique from functools import total_ordering @@ -19,6 +19,7 @@ from sentry.models.organizationmember import OrganizationMember from sentry.models.project import Project from sentry.models.projectkey import ProjectKey + from sentry.monitors.constants import PermitCheckInStatus from sentry.profiles.utils import Profile from sentry.quotas.types import SeatObject @@ -29,7 +30,7 @@ class QuotaScope(IntEnum): PROJECT = 2 KEY = 3 - def api_name(self): + def api_name(self) -> str: return self.name.lower() @@ -138,7 +139,7 @@ class QuotaGroupBy: max_cardinality: int dimensions: tuple[QuotaDimension, ...] - def to_json(self) -> dict[str, Any]: + def to_json(self) -> dict[str, object]: return { "maxCardinality": self.max_cardinality, "dimensions": [d.value for d in self.dimensions], @@ -198,18 +199,28 @@ class QuotaConfig: "group_by", ] + id: str | None + categories: set[DataCategory] + scope: QuotaScope + scope_id: str | None + limit: int | None + window: int | None + reason_code: str | None + namespace: str | None + group_by: QuotaGroupBy | None + def __init__( self, - id=None, - categories=None, - scope=None, - scope_id=None, + id: str | None = None, + categories: Iterable[DataCategory] | None = None, + scope: QuotaScope | None = None, + scope_id: str | int | None = None, limit: int | None = None, - window=None, - reason_code=None, - namespace=None, + window: int | None = None, + reason_code: str | None = None, + namespace: str | None = None, group_by: QuotaGroupBy | None = None, - ): + ) -> None: if limit is not None: assert reason_code, "reason code required for fallible quotas" assert isinstance(limit, int), "limit must be an integer" @@ -238,14 +249,14 @@ def __init__( self.group_by = group_by @property - def should_track(self): + def should_track(self) -> bool: """ Whether the quotas service should track this quota. """ return self.id is not None and self.window is not None - def to_json(self): + def to_json(self) -> dict[str, object]: categories = None if self.categories: categories = [c.api_name() for c in self.categories] @@ -342,7 +353,7 @@ def __post_init__(self) -> None: raise ValueError("`reason` must be specified when not assignable") -def index_data_category(event_type: str | None, organization) -> DataCategory: +def index_data_category(event_type: str | None, organization: Organization | None) -> DataCategory: if event_type == "transaction" and features.has( "organizations:transaction-metrics-extraction", organization ): @@ -380,7 +391,7 @@ class Quota(Service): "update_monitor_slug", ) - def __init__(self, **options): + def __init__(self, **options: object) -> None: pass def get_quotas( @@ -402,7 +413,14 @@ def get_quotas( """ return [] - def refund(self, project, key=None, timestamp=None, category=None, quantity=None): + def refund( + self, + project: Project, + key: ProjectKey | None = None, + timestamp: float | None = None, + category: DataCategory | None = None, + quantity: int | None = None, + ) -> None: """ Signals that data counted against quotas was dropped, and refunds the previously consumed quota. @@ -423,7 +441,12 @@ def refund(self, project, key=None, timestamp=None, category=None, quantity=None attachment in bytes. """ - def get_event_retention(self, organization, category: DataCategory | None = None, **kwargs): + def get_event_retention( + self, + organization: Organization | None, + category: DataCategory | None = None, + **kwargs: object, + ) -> int | None: """ Returns the retention for events in the given organization in days. Returns ``None`` if events are to be stored indefinitely. @@ -435,8 +458,11 @@ def get_event_retention(self, organization, category: DataCategory | None = None return _limit_from_settings(options.get("system.event-retention-days")) def get_downsampled_event_retention( - self, organization, category: DataCategory | None = None, **kwargs - ): + self, + organization: Organization | None, + category: DataCategory | None = None, + **kwargs: object, + ) -> int: """ Returns the retention for downsampled events in the given organization in days. Returning ``0`` means downsampled event retention will default to the value of ``get_event_retention``. @@ -444,22 +470,24 @@ def get_downsampled_event_retention( return 0 def get_retentions( - self, organization: Organization, **kwargs + self, organization: Organization | None, **kwargs: object ) -> Mapping[DataCategory, RetentionSettings]: return {} - def get_trimming_configs(self, organization: Organization, **kwargs) -> TrimmingConfigs: + def get_trimming_configs( + self, organization: Organization | None, **kwargs: object + ) -> TrimmingConfigs: """ Returns per-data-category trimming settings. """ return {} - def validate(self): + def validate(self) -> None: """ Validates that the quota service is operational. """ - def get_key_quota(self, key): + def get_key_quota(self, key: ProjectKey) -> tuple[int | None, int | None]: from sentry import features # XXX(epurkhiser): Avoid excessive feature manager checks (which can be @@ -478,7 +506,7 @@ def get_key_quota(self, key): limit, window = key.rate_limit return _limit_from_settings(limit), window - def get_abuse_quotas(self, org): + def get_abuse_quotas(self, org: Organization) -> Iterator[QuotaConfig]: # Per-project abuse quotas for errors, transactions, attachments, sessions. global_abuse_window = options.get("project-abuse-quota.window") @@ -565,7 +593,7 @@ def get_abuse_quotas(self, org): namespace=quota.namespace, ) - def get_monitor_quota(self, project): + def get_monitor_quota(self, project: Project) -> tuple[int | None, int | None]: from sentry.monitors.rate_limit import get_project_monitor_quota return get_project_monitor_quota(project) @@ -637,7 +665,9 @@ def remove_seat(self, seat_object: SeatObject) -> None: Removes an assigned seat. """ - def check_accept_monitor_checkin(self, project_id: int, monitor_slug: str): + def check_accept_monitor_checkin( + self, project_id: int, monitor_slug: str + ) -> PermitCheckInStatus: """ Will return a `PermitCheckInStatus`. """ @@ -645,7 +675,7 @@ def check_accept_monitor_checkin(self, project_id: int, monitor_slug: str): return PermitCheckInStatus.ACCEPT - def update_monitor_slug(self, previous_slug: str, new_slug: str, project_id: int): + def update_monitor_slug(self, previous_slug: str, new_slug: str, project_id: int) -> None: """ Updates a monitor seat assignment's slug. """ diff --git a/src/sentry/quotas/redis.py b/src/sentry/quotas/redis.py index f3c5b4ad7750..d28404f0c2e7 100644 --- a/src/sentry/quotas/redis.py +++ b/src/sentry/quotas/redis.py @@ -59,12 +59,16 @@ def __get_redis_client(self, routing_key: str) -> RedisCluster | rb.RoutingClien raise AssertionError("unreachable") def __get_redis_key( - self, quota: QuotaConfig, timestamp: float, shift: int, organization_id: int + self, + quota: QuotaConfig, + window: int, + timestamp: float, + shift: int, + organization_id: int, ) -> str: scope_id = quota.scope_id or "" if quota.scope != QuotaScope.ORGANIZATION else "" local_key = f"{quota.id}{{{organization_id}}}{scope_id}" - interval = quota.window - return f"{self.namespace}:{local_key}:{int((timestamp - shift) // interval)}" + return f"{self.namespace}:{local_key}:{int((timestamp - shift) // window)}" def get_quotas( self, @@ -186,12 +190,14 @@ def refund( pipe = client.pipeline() for quota in quotas: + if quota.window is None: + continue shift = project.organization_id % quota.window # kind of arbitrary, but seems like we don't want this to expire til we're # sure the window is over? expiry = self.get_next_period_start(quota.window, shift, timestamp) + self.grace return_key = self.get_refunded_quota_key( - self.__get_redis_key(quota, timestamp, shift, project.organization_id) + self.__get_redis_key(quota, quota.window, timestamp, shift, project.organization_id) ) pipe.incr(return_key, quantity) pipe.expireat(return_key, int(expiry)) diff --git a/src/sentry/receivers/features.py b/src/sentry/receivers/features.py index 90e14d24e1b7..96d73b7a3679 100644 --- a/src/sentry/receivers/features.py +++ b/src/sentry/receivers/features.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Iterable + import sentry_sdk from django.db.models.signals import post_save @@ -36,7 +38,7 @@ from sentry.models.grouptombstone import GroupTombstone from sentry.models.organization import Organization from sentry.models.project import Project -from sentry.services.eventstore.models import GroupEvent +from sentry.services.eventstore.models import BaseEvent, GroupEvent from sentry.signals import ( advanced_search, advanced_search_feature_gated, @@ -123,7 +125,7 @@ def record_first_event(project, **kwargs): ) -def record_event_processed(project, event, **kwargs): +def record_event_processed(project: Project, event: BaseEvent, **kwargs: object) -> None: return record_generic_event_processed( project, platform=event.group.platform if event.group else event.platform, @@ -137,16 +139,16 @@ def record_event_processed(project, event, **kwargs): def record_generic_event_processed( - project, - platform=None, - release=None, - environment=None, - user_keys=None, - tag_keys=None, - has_sourcemap=False, - has_breadcrumbs=False, - **kwargs, -): + project: Project, + platform: str | None = None, + release: str | None = None, + environment: str | None = None, + user_keys: Iterable[str] | None = None, + tag_keys: Iterable[str] | None = None, + has_sourcemap: bool = False, + has_breadcrumbs: object = False, + **kwargs: object, +) -> None: feature_slugs = [] # Platform diff --git a/src/sentry/relay/config/__init__.py b/src/sentry/relay/config/__init__.py index 66135f6057ad..e104b0b9b20c 100644 --- a/src/sentry/relay/config/__init__.py +++ b/src/sentry/relay/config/__init__.py @@ -182,7 +182,9 @@ def get_filter_settings(project: Project) -> Mapping[str, Any]: return filter_settings -def get_quotas(project: Project, keys: Iterable[ProjectKey] | None = None) -> list[str]: +def get_quotas( + project: Project, keys: Iterable[ProjectKey] | None = None +) -> list[dict[str, object]]: try: computed_quotas = [ quota.to_json() for quota in quotas.backend.get_quotas(project, keys=keys) diff --git a/src/sentry/relay/datascrubbing.py b/src/sentry/relay/datascrubbing.py index 52da933f885e..8ebaa9c7c665 100644 --- a/src/sentry/relay/datascrubbing.py +++ b/src/sentry/relay/datascrubbing.py @@ -18,6 +18,7 @@ from sentry.utils.safe import safe_execute if TYPE_CHECKING: + from sentry.models.organization import Organization from sentry.models.project import Project @@ -144,7 +145,7 @@ def _merge_pii_configs(prefixes_and_configs: list[tuple[str, dict[str, Any]]]) - return merged_config -def validate_pii_config_update(organization, value): +def validate_pii_config_update(organization: Organization, value: str | None) -> str | None: if not value: return value @@ -156,11 +157,11 @@ def validate_pii_config_update(organization, value): return value -def validate_pii_selectors(selectors): +def validate_pii_selectors(selectors: list[str]) -> list[str]: if not selectors: return selectors - errors = list() + errors: list[str] = [] for line, selector in enumerate(selectors, start=1): try: validate_pii_selector(selector) diff --git a/src/sentry/releases/endpoints/organization_release_details.py b/src/sentry/releases/endpoints/organization_release_details.py index 7bfc1255b140..198cf6510649 100644 --- a/src/sentry/releases/endpoints/organization_release_details.py +++ b/src/sentry/releases/endpoints/organization_release_details.py @@ -1,5 +1,7 @@ +from typing import TypedDict + import sentry_sdk -from django.db.models import Q +from django.db.models import Q, QuerySet from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.exceptions import ParseError, ValidationError from rest_framework.request import Request @@ -10,7 +12,7 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import ReleaseAnalyticsMixin, cell_silo_endpoint -from sentry.api.bases.organization import OrganizationReleasesBaseEndpoint +from sentry.api.bases.organization import FilterParamsDateNotNull, OrganizationReleasesBaseEndpoint from sentry.api.endpoints.organization_releases import ( _release_suffix, add_environment_to_queryset, @@ -75,6 +77,11 @@ class OrganizationReleaseSerializer(ReleaseSerializer): ) +class _ReleaseDateQueryAndOrder(TypedDict): + date_query_q: Q + order_by: list[str] + + def add_status_filter_to_queryset(queryset, status_filter): """ Function that adds status filter on a queryset @@ -108,7 +115,9 @@ def add_query_filter_to_queryset(queryset, query): class OrganizationReleaseDetailsPaginationMixin: @staticmethod - def __get_prev_release_date_query_q_and_order_by(release): + def __get_prev_release_date_query_q_and_order_by( + release: Release, + ) -> _ReleaseDateQueryAndOrder: """ Method that takes a release and returns a dictionary containing a date query Q expression and order by columns required to fetch previous release to that passed in release on date @@ -121,7 +130,9 @@ def __get_prev_release_date_query_q_and_order_by(release): } @staticmethod - def __get_next_release_date_query_q_and_order_by(release): + def __get_next_release_date_query_q_and_order_by( + release: Release, + ) -> _ReleaseDateQueryAndOrder: """ Method that takes a release and returns a dictionary containing a date query Q expression and order by columns required to fetch next release to that passed in release on date @@ -135,13 +146,13 @@ def __get_next_release_date_query_q_and_order_by(release): @staticmethod def __get_release_according_to_filters_and_order_by_for_date_sort( - org, - filter_params, - date_query_q, - order_by, - status_filter, - query, - ): + org: Organization, + filter_params: FilterParamsDateNotNull, + date_query_q: Q, + order_by: list[str], + status_filter: str, + query: str | None, + ) -> QuerySet[Release]: """ Helper function that executes a query on Release table based on different filters provided as inputs and orders that query based on `order_by` input provided @@ -156,7 +167,7 @@ def __get_release_according_to_filters_and_order_by_for_date_sort( Queryset that contains one element that represents either next or previous release based on the inputs """ - queryset = Release.objects.filter( + queryset: QuerySet[Release] = Release.objects.filter( date_query_q, organization=org, projects__id__in=filter_params["project_id"], @@ -178,14 +189,14 @@ def __get_release_according_to_filters_and_order_by_for_date_sort( def get_adjacent_releases_to_current_release( self, - release, - org, - filter_params, - stats_period, - sort, - status_filter, - query, - ): + release: Release, + org: Organization, + filter_params: FilterParamsDateNotNull, + stats_period: str | None, + sort: str, + status_filter: str, + query: str | None, + ) -> dict[str, str | None]: """ Method that returns the prev and next release to a current release based on different sort options @@ -202,22 +213,25 @@ def get_adjacent_releases_to_current_release( previous release and next release respectively """ if sort == "date": - release_common_filters = { - "org": org, - "filter_params": filter_params, - "status_filter": status_filter, - "query": query, - } - # Get previous queryset of current release + prev_date_query = self.__get_prev_release_date_query_q_and_order_by(release) prev_release_list = self.__get_release_according_to_filters_and_order_by_for_date_sort( - **release_common_filters, - **self.__get_prev_release_date_query_q_and_order_by(release), + org=org, + filter_params=filter_params, + date_query_q=prev_date_query["date_query_q"], + order_by=prev_date_query["order_by"], + status_filter=status_filter, + query=query, ) # Get next queryset of current release + next_date_query = self.__get_next_release_date_query_q_and_order_by(release) next_release_list = self.__get_release_according_to_filters_and_order_by_for_date_sort( - **release_common_filters, - **self.__get_next_release_date_query_q_and_order_by(release), + org=org, + filter_params=filter_params, + date_query_q=next_date_query["date_query_q"], + order_by=next_date_query["order_by"], + status_filter=status_filter, + query=query, ) else: raise InvalidSortException @@ -271,7 +285,13 @@ def __get_top_of_queryset_release_version_based_on_order_by(org, proj_and_env_di return None return release.version - def get_first_and_last_releases(self, org, environment, project_id, sort): + def get_first_and_last_releases( + self, + org: Organization, + environment: list[str] | None, + project_id: list[int], + sort: str, + ) -> dict[str, str | None]: """ Method that returns the first and last release based on `date_added` Inputs:- @@ -287,7 +307,7 @@ def get_first_and_last_releases(self, org, environment, project_id, sort): last_release_version = None if sort == "date": - proj_and_env_dict = {"project_id": project_id} + proj_and_env_dict: dict[str, list[int] | list[str]] = {"project_id": project_id} if environment is not None: proj_and_env_dict["environment"] = environment @@ -356,7 +376,7 @@ class OrganizationReleaseDetailsEndpoint( examples=OrganizationExamples.RELEASE_DETAILS, ) def get( - self, request: Request, organization, version + self, request: Request, organization: Organization, version: str ) -> Response[ReleaseSerializerResponse] | Response[DetailResponse]: """ @@ -490,7 +510,7 @@ def get( examples=OrganizationExamples.RELEASE_DETAILS, ) def put( - self, request: Request, organization: Organization, version + self, request: Request, organization: Organization, version: str ) -> Response[ReleaseSerializerResponse] | Response[ValidationErrorResponse]: """ @@ -623,7 +643,7 @@ def put( }, ) def delete( - self, request: Request, organization, version + self, request: Request, organization: Organization, version: str ) -> Response[None] | Response[DetailResponse]: """ diff --git a/src/sentry/runner/commands/presenters/consolepresenter.py b/src/sentry/runner/commands/presenters/consolepresenter.py index c20c0087cced..1bd15db84189 100644 --- a/src/sentry/runner/commands/presenters/consolepresenter.py +++ b/src/sentry/runner/commands/presenters/consolepresenter.py @@ -3,7 +3,7 @@ import click from yaml import safe_dump -from sentry.runner.commands.presenters.optionspresenter import OptionsPresenter +from sentry.runner.commands.presenters.optionspresenter import ExpectedOptionType, OptionsPresenter class ConsolePresenter(OptionsPresenter): @@ -33,7 +33,7 @@ def __init__(self) -> None: self.unset_options: list[str] = [] self.not_writable_options: list[tuple[str, str]] = [] self.unregistered_options: list[str] = [] - self.invalid_type_options: list[tuple[str, type, type]] = [] + self.invalid_type_options: list[tuple[str, type, ExpectedOptionType]] = [] def flush(self) -> None: import logging @@ -98,6 +98,6 @@ def invalid_type( self, key: str, got_type: type, - expected_type: type, + expected_type: ExpectedOptionType, ) -> None: self.invalid_type_options.append((key, got_type, expected_type)) diff --git a/src/sentry/runner/commands/presenters/optionspresenter.py b/src/sentry/runner/commands/presenters/optionspresenter.py index f333b2acf89c..b1f83b4bd0eb 100644 --- a/src/sentry/runner/commands/presenters/optionspresenter.py +++ b/src/sentry/runner/commands/presenters/optionspresenter.py @@ -1,6 +1,10 @@ from abc import ABC, abstractmethod from typing import Any +from sentry.utils.types import Type as OptionValueType + +ExpectedOptionType = type[object] | OptionValueType[object] + class OptionsPresenter(ABC): """ @@ -48,5 +52,5 @@ def unregistered(self, key: str) -> None: raise NotImplementedError @abstractmethod - def invalid_type(self, key: str, got_type: type, expected_type: type) -> None: + def invalid_type(self, key: str, got_type: type, expected_type: ExpectedOptionType) -> None: raise NotImplementedError diff --git a/src/sentry/runner/commands/presenters/webhookpresenter.py b/src/sentry/runner/commands/presenters/webhookpresenter.py index 3ef198110bbd..4dd2b427afc5 100644 --- a/src/sentry/runner/commands/presenters/webhookpresenter.py +++ b/src/sentry/runner/commands/presenters/webhookpresenter.py @@ -7,7 +7,7 @@ from django.conf import settings from sentry import options -from sentry.runner.commands.presenters.optionspresenter import OptionsPresenter +from sentry.runner.commands.presenters.optionspresenter import ExpectedOptionType, OptionsPresenter from sentry.utils import json @@ -32,7 +32,7 @@ def __init__(self, source: str, timestamp: float | None = None) -> None: self.unset_options: list[str] = [] self.not_writable_options: list[tuple[str, str]] = [] self.unregistered_options: list[str] = [] - self.invalid_type_options: list[tuple[str, type, type]] = [] + self.invalid_type_options: list[tuple[str, type, ExpectedOptionType]] = [] @staticmethod def is_webhook_enabled() -> bool: @@ -123,7 +123,7 @@ def invalid_type( self, key: str, got_type: type, - expected_type: type, + expected_type: ExpectedOptionType, ) -> None: self.invalid_type_options.append((key, got_type, expected_type)) diff --git a/src/sentry/sdk_updates.py b/src/sentry/sdk_updates.py index bdd9fcd7dde0..7a0545b67071 100644 --- a/src/sentry/sdk_updates.py +++ b/src/sentry/sdk_updates.py @@ -1,7 +1,8 @@ from __future__ import annotations import logging -from typing import TypedDict +from collections.abc import Iterator, Mapping, Sequence +from typing import Protocol, TypedDict from django.conf import settings from django.core.cache import cache @@ -14,13 +15,19 @@ class SdkSetupState: - def __init__(self, sdk_name, sdk_version, modules, integrations): - self.sdk_name = sdk_name - self.sdk_version = sdk_version - self.modules = dict(modules or ()) - self.integrations = list(integrations or ()) - - def copy(self): + def __init__( + self, + sdk_name: str | None, + sdk_version: str | None, + modules: Mapping[str, str] | Sequence[tuple[str, str]] | None, + integrations: Sequence[str] | None, + ) -> None: + self.sdk_name: str | None = sdk_name + self.sdk_version: str | None = sdk_version + self.modules: dict[str, str] = dict(modules or ()) + self.integrations: list[str] = list(integrations or ()) + + def copy(self) -> SdkSetupState: return type(self)( sdk_name=self.sdk_name, sdk_version=self.sdk_version, @@ -29,7 +36,7 @@ def copy(self): ) @classmethod - def from_event_json(cls, event_data): + def from_event_json(cls, event_data: Mapping[str, object]) -> SdkSetupState: sdk_name = get_path(event_data, "sdk", "name") if sdk_name: sdk_name = sdk_name.lower().rsplit(":", 1)[0] @@ -46,14 +53,21 @@ def from_event_json(cls, event_data): class SdkIndexState: - def __init__(self, sdk_versions=None, deprecated_sdks=None, sdk_supported_modules=None): - self.sdk_versions = sdk_versions or get_sdk_versions() - self.deprecated_sdks = deprecated_sdks or settings.DEPRECATED_SDKS - self.sdk_supported_modules = sdk_supported_modules or SDK_SUPPORTED_MODULES + def __init__( + self, + sdk_versions: Mapping[str, str] | None = None, + deprecated_sdks: Mapping[str, str] | None = None, + sdk_supported_modules: Sequence[SupportedModule] | None = None, + ) -> None: + self.sdk_versions: Mapping[str, str] = sdk_versions or get_sdk_versions() + self.deprecated_sdks: Mapping[str, str] = deprecated_sdks or settings.DEPRECATED_SDKS + self.sdk_supported_modules: Sequence[SupportedModule] = ( + sdk_supported_modules or SDK_SUPPORTED_MODULES + ) class Suggestion: - def to_json(self): + def to_json(self) -> dict[str, object]: raise NotImplementedError() def __eq__(self, other): @@ -61,18 +75,18 @@ def __eq__(self, other): class EnableIntegrationSuggestion(Suggestion): - def __init__(self, integration_name, integration_url): + def __init__(self, integration_name: str, integration_url: str) -> None: self.integration_name = integration_name self.integration_url = integration_url - def to_json(self): + def to_json(self) -> dict[str, object]: return { "type": "enableIntegration", "integrationName": self.integration_name, "integrationUrl": self.integration_url, } - def get_new_state(self, old_state): + def get_new_state(self, old_state: SdkSetupState) -> SdkSetupState: if self.integration_name in old_state.integrations: return old_state @@ -82,12 +96,14 @@ def get_new_state(self, old_state): class UpdateSDKSuggestion(Suggestion): - def __init__(self, sdk_name, new_sdk_version, ignore_patch_version): + def __init__( + self, sdk_name: str, new_sdk_version: str | None, ignore_patch_version: bool + ) -> None: self.sdk_name = sdk_name self.new_sdk_version = new_sdk_version self.ignore_patch_version = ignore_patch_version - def to_json(self): + def to_json(self) -> dict[str, object]: return { "type": "updateSdk", "sdkName": self.sdk_name, @@ -95,7 +111,7 @@ def to_json(self): "sdkUrl": get_sdk_urls().get(self.sdk_name), } - def get_new_state(self, old_state): + def get_new_state(self, old_state: SdkSetupState) -> SdkSetupState: if self.new_sdk_version is None: return old_state @@ -103,6 +119,9 @@ def get_new_state(self, old_state): if self.ignore_patch_version: new_sdk_version = ".".join(v for v in new_sdk_version.split(".")[:2]) + if old_state.sdk_version is None: + return old_state + try: has_newer_version = Version(old_state.sdk_version) < Version(new_sdk_version) except Exception: @@ -123,18 +142,18 @@ class ChangeSDKSuggestion(Suggestion): multiple SDKs in e.g. .NET. """ - def __init__(self, new_sdk_name, module_names=None): + def __init__(self, new_sdk_name: str, module_names: Sequence[str] | None = None) -> None: self.new_sdk_name = new_sdk_name self.module_names = module_names - def to_json(self): + def to_json(self) -> dict[str, object]: return { "type": "changeSdk", "newSdkName": self.new_sdk_name, "sdkUrl": get_sdk_urls().get(self.new_sdk_name), } - def get_new_state(self, old_state): + def get_new_state(self, old_state: SdkSetupState) -> SdkSetupState: if old_state.sdk_name == self.new_sdk_name: return old_state @@ -151,7 +170,13 @@ class SupportedModule(TypedDict): sdk_version_added: str module_name: str module_version_min: str - suggestion: Suggestion + suggestion: StatefulSuggestion + + +class StatefulSuggestion(Protocol): + def to_json(self) -> dict[str, object]: ... + + def get_new_state(self, old_state: SdkSetupState) -> SdkSetupState: ... SDK_SUPPORTED_MODULES: list[SupportedModule] = [ @@ -368,7 +393,7 @@ def get_sdk_index(): return cache.get(SDK_INDEX_CACHE_KEY) or {} -def get_sdk_versions(): +def get_sdk_versions() -> dict[str, str]: try: rv = settings.SDK_VERSIONS rv.update((key, info["version"]) for (key, info) in get_sdk_index().items()) @@ -378,7 +403,7 @@ def get_sdk_versions(): return {} -def get_sdk_urls(): +def get_sdk_urls() -> dict[str, str]: try: rv = dict(settings.SDK_URLS) rv.update((key, info.get("main_docs_url", "")) for (key, info) in get_sdk_index().items()) @@ -388,7 +413,9 @@ def get_sdk_urls(): return {} -def _get_suggested_updates_step(setup_state, index_state, ignore_patch_version): +def _get_suggested_updates_step( + setup_state: SdkSetupState, index_state: SdkIndexState, ignore_patch_version: bool +) -> Iterator[StatefulSuggestion]: if not setup_state.sdk_name or not setup_state.sdk_version: return @@ -435,8 +462,11 @@ def _get_suggested_updates_step(setup_state, index_state, ignore_patch_version): def get_suggested_updates( - setup_state, index_state=None, parent_suggestions=None, ignore_patch_version=True -): + setup_state: SdkSetupState, + index_state: SdkIndexState | None = None, + parent_suggestions: list[StatefulSuggestion] | None = None, + ignore_patch_version: bool = True, +) -> Iterator[dict[str, object]]: if index_state is None: index_state = SdkIndexState() diff --git a/src/sentry/search/events/fields.py b/src/sentry/search/events/fields.py index 5ca1f8897ae6..30d955bd66c4 100644 --- a/src/sentry/search/events/fields.py +++ b/src/sentry/search/events/fields.py @@ -6,7 +6,7 @@ from copy import copy, deepcopy from datetime import datetime, timezone from re import Match -from typing import Any, NamedTuple +from typing import Any, NamedTuple, Self import sentry_sdk from sentry_relay.consts import SPAN_STATUS_NAME_TO_CODE @@ -484,7 +484,7 @@ def format_column_arguments(column_args, arguments) -> None: column_args[i] = arguments[column_args[i].arg] -def _lookback(columns, j, string): +def _lookback(columns: str, j: int, string: str) -> bool: """For parse_arguments, check that the current character is preceeded by string""" if j < len(string): return False @@ -675,7 +675,9 @@ def parse_combinator(function: str) -> tuple[str, str | None]: return function, None -def parse_function(field, match=None, err_msg=None): +def parse_function( + field: str, match: Match[str] | None = None, err_msg: str | None = None +) -> tuple[str, list[str], str | None]: if not match: match = is_function(field) @@ -716,7 +718,9 @@ def get_function_alias(field: str) -> str: return get_function_alias_with_columns(function, columns) -def get_function_alias_with_columns(function_name, columns, prefix=None) -> str: +def get_function_alias_with_columns( + function_name: str, columns: Sequence[str], prefix: str | None = None +) -> str: columns = re.sub( r"[^\w]", "_", @@ -838,7 +842,7 @@ def normalize( ) -> str | float | datetime | list[Any] | None: return value - def get_type(self, _) -> str: + def get_type(self, _: str) -> str: raise InvalidFunctionArgument(f"{self.name} has no type defined") @@ -932,7 +936,7 @@ def __init__(self, name: str): super().__init__(name) self.has_default = True - def get_default(self, _) -> None: + def get_default(self, _: ParamsType) -> None: return None def normalize(self, value: str, params: ParamsType, combinator: Combinator | None) -> None: @@ -984,7 +988,7 @@ def __init__(self, name: str, start: float | None, end: float | None): super().__init__(name, start, end) self.has_default = True - def get_default(self, _) -> None: + def get_default(self, _: ParamsType) -> None: return None def normalize( @@ -1096,7 +1100,7 @@ def __init__(self, name: str, **kwargs): super().__init__(name, **kwargs) self.has_default = True - def get_default(self, _) -> None: + def get_default(self, _: ParamsType) -> None: return None def normalize( @@ -1384,22 +1388,22 @@ def __init__( self.validate() @property - def required_args_count(self): + def required_args_count(self) -> int: return len(self.required_args) @property - def optional_args_count(self): + def optional_args_count(self) -> int: return len(self.optional_args) @property - def total_args_count(self): + def total_args_count(self) -> int: return self.required_args_count + self.optional_args_count @property def args(self): return self.required_args + self.optional_args - def alias_as(self, name): + def alias_as(self, name: str) -> Self: """Create a copy of this function to be used as an alias""" alias = copy(self) alias.name = name @@ -1534,7 +1538,7 @@ def validate_argument_count(self, field: str, arguments: list[str]) -> None: f"{field}: expected at most {total_args_count:g} argument(s) but got {args_count:g} argument(s)" ) - def validate_result_type(self, result_type) -> None: + def validate_result_type(self, result_type: str | None) -> None: assert result_type is None or result_type in RESULT_TYPES, ( f"{self.name}: result type {result_type} not one of {list(RESULT_TYPES)}" ) diff --git a/src/sentry/search/exceptions.py b/src/sentry/search/exceptions.py index 6924f9ae808f..d2c7445c55c5 100644 --- a/src/sentry/search/exceptions.py +++ b/src/sentry/search/exceptions.py @@ -4,7 +4,7 @@ class InvalidIssueSearchQuery(InvalidSearchQuery): """Raised when an issue filter references non-existent issue IDs.""" - def __init__(self, invalid_ids: list[str]): + def __init__(self, invalid_ids: list[str]) -> None: self.invalid_ids = invalid_ids super().__init__(f"Issue IDs do not exist: {invalid_ids}") diff --git a/src/sentry/search/snuba/executors.py b/src/sentry/search/snuba/executors.py index 7ff63f00d9ce..ff338c1a811b 100644 --- a/src/sentry/search/snuba/executors.py +++ b/src/sentry/search/snuba/executors.py @@ -1340,9 +1340,12 @@ def _execute_postgres_sort( attributes={"sentry.op": "search.postgres_sort.native_order_by"}, ): ordered_queryset, order_by = strategy.native_order_by(group_queryset) - return Paginator( - ordered_queryset.using_replica(), order_by=order_by, **paginator_options - ).get_result(limit, cursor, count_hits=count_hits, max_hits=max_hits) + return cast( + CursorResult[Group], + Paginator( + ordered_queryset.using_replica(), order_by=order_by, **paginator_options + ).get_result(limit, cursor, count_hits=count_hits, max_hits=max_hits), + ) max_candidates = options.get("snuba.search.max-pre-snuba-candidates") with traces.start_span( @@ -1496,8 +1499,13 @@ def _execute_postgres_sort( ).get_result(limit, cursor, count_hits=count_hits, max_hits=max_hits) groups = Group.objects.in_bulk(paginator_results.results) - paginator_results.results = [groups[k] for k in paginator_results.results if k in groups] - return paginator_results + return CursorResult( + results=[groups[k] for k in paginator_results.results if k in groups], + next=paginator_results.next, + prev=paginator_results.prev, + hits=paginator_results.hits, + max_hits=paginator_results.max_hits, + ) def _execute_inverted_chunk_sort( self, @@ -1707,8 +1715,13 @@ def _execute_inverted_chunk_sort( sentry_sdk.set_tag("search.progress_inverted_budget_exhausted", "true") groups = Group.objects.in_bulk(paginator_results.results) - paginator_results.results = [groups[k] for k in paginator_results.results if k in groups] - return paginator_results + return CursorResult( + results=[groups[k] for k in paginator_results.results if k in groups], + next=paginator_results.next, + prev=paginator_results.prev, + hits=paginator_results.hits, + max_hits=paginator_results.max_hits, + ) def query( self, @@ -1855,7 +1868,10 @@ def query( paginator = DateTimePaginator(group_queryset, "-last_seen", **paginator_options) # When it's a simple django-only search, we count_hits like normal - results = paginator.get_result(limit, cursor, count_hits=count_hits, max_hits=max_hits) + results = cast( + CursorResult[Group], + paginator.get_result(limit, cursor, count_hits=count_hits, max_hits=max_hits), + ) metrics.timing( "snuba.search.query", (timezone.now() - now).total_seconds(), @@ -1939,7 +1955,7 @@ def query( else: return self.empty_result - paginator_results = self.empty_result + paginator_results: CursorResult[Group] | CursorResult[int] = self.empty_result result_groups: list[tuple[int, Any]] = [] result_group_ids: set[int] = set() @@ -2055,7 +2071,13 @@ def query( metrics.distribution("snuba.search.num_chunks", num_chunks) groups = Group.objects.in_bulk(paginator_results.results) - paginator_results.results = [groups[k] for k in paginator_results.results if k in groups] + paginator_results = CursorResult( + results=[groups[k] for k in paginator_results.results if k in groups], + next=paginator_results.next, + prev=paginator_results.prev, + hits=paginator_results.hits, + max_hits=paginator_results.max_hits, + ) metrics.timing( "snuba.search.query", diff --git a/src/sentry/seer/agent/tools.py b/src/sentry/seer/agent/tools.py index aac72696e306..6d576e75afd3 100644 --- a/src/sentry/seer/agent/tools.py +++ b/src/sentry/seer/agent/tools.py @@ -17,8 +17,8 @@ from sentry.api.client import client from sentry.api.endpoints.organization_events_timeseries import TOP_EVENTS_DATASETS from sentry.api.serializers.base import serialize -from sentry.api.serializers.models.activity import ActivitySerializer -from sentry.api.serializers.models.commit import CommitSerializer +from sentry.api.serializers.models.activity import ActivitySerializer, ActivitySerializerResponse +from sentry.api.serializers.models.commit import CommitSerializer, CommitSerializerResponse from sentry.api.serializers.models.event import EventSerializer from sentry.api.serializers.models.group import GroupSerializer from sentry.api.utils import MAX_STATS_PERIOD, default_start_end_dates, get_date_range_from_params @@ -1435,11 +1435,11 @@ def get_issue_details( type__in=_SEER_EXPLORER_ACTOR_REQUIRED_ACTIVITY_TYPES, user_id__isnull=False, ) - activities = ( + activities: list[Activity] = list( Activity.objects.filter(group=group).filter(activity_filter).order_by("-datetime")[:50] ) - serialized_activities = serialize( - list(activities), user=None, serializer=ActivitySerializer(resolve_mentions=True) + serialized_activities: list[ActivitySerializerResponse] = serialize( + activities, user=None, serializer=ActivitySerializer(resolve_mentions=True) ) except Exception: logger.exception( @@ -1605,27 +1605,27 @@ def _get_stack_commits(self) -> list[dict[str, Any]]: # Batch-serialize every commit in one call: CommitSerializer.get_attrs runs # repository and pull request queries per invocation, so serializing one # commit at a time would be an N+1. - commits_by_id = { - commit.id: commit + commits_by_id: dict[int, Commit] = { + cast(int, commit.id): commit for entry in author_commits - for commit, _ in entry.get("commits", []) + for commit, _ in entry.get("commits", list[tuple[Commit, int]]()) } - serialized_by_id = { + commit_list: list[Commit] = list(commits_by_id.values()) + serialized_commits: list[CommitSerializerResponse] = serialize( + commit_list, + serializer=CommitSerializer(exclude=["author"]), + ) + + serialized_by_id: dict[int, CommitSerializerResponse] = { commit_id: serialized - for commit_id, serialized in zip( - commits_by_id, - serialize( - list(commits_by_id.values()), - serializer=CommitSerializer(exclude=["author"]), - ), - ) + for commit_id, serialized in zip(commits_by_id, serialized_commits) } stack_commits = [ { "author": entry.get("author"), "commits": [ - {**serialized_by_id[commit.id], "score": score} - for commit, score in entry.get("commits", []) + {**serialized_by_id[cast(int, commit.id)], "score": score} + for commit, score in entry.get("commits", list[tuple[Commit, int]]()) ], } for entry in author_commits diff --git a/src/sentry/sentry_apps/api/bases/sentryapps.py b/src/sentry/sentry_apps/api/bases/sentryapps.py index 9e53ac69c49a..6e37b5a30f83 100644 --- a/src/sentry/sentry_apps/api/bases/sentryapps.py +++ b/src/sentry/sentry_apps/api/bases/sentryapps.py @@ -1,13 +1,14 @@ from __future__ import annotations import logging -from collections.abc import Collection, Mapping, Sequence +from collections.abc import Mapping, Sequence from typing import Any import sentry_sdk from rest_framework.permissions import BasePermission from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.views import APIView from sentry import options from sentry.api.authentication import ClientIdSecretAuthentication, JWTClientSecretAuthentication @@ -70,7 +71,9 @@ class SentryAppsPermission(SentryPermission): "POST": ("org:write", "org:admin"), } - def has_object_permission(self, request: Request, view, context: RpcUserOrganizationContext): + def has_object_permission( + self, request: Request, view: APIView, context: RpcUserOrganizationContext + ) -> bool: if not hasattr(request, "user") or not request.user: return False @@ -156,7 +159,7 @@ def _handle_sentry_app_exception(self, exception: Exception): class SentryAppsBaseEndpoint(IntegrationPlatformEndpoint): permission_classes: tuple[type[BasePermission], ...] = (SentryAppsAndStaffPermission,) - def _get_organization_slug(self, request: Request): + def _get_organization_slug(self, request: Request) -> str: organization_slug = request.data.get("organization") if not organization_slug or not isinstance(organization_slug, str): error_message = "Please provide a valid value for the 'organization' field." @@ -241,7 +244,9 @@ class SentryAppPermission(SentryPermission): "DELETE": ("org:admin",), } - def has_object_permission(self, request: Request, view, sentry_app: RpcSentryApp | SentryApp): + def has_object_permission( + self, request: Request, view: APIView, sentry_app: RpcSentryApp | SentryApp + ) -> bool: scope_map = self._scopes_for_sentry_app(sentry_app) if not hasattr(request, "user") or not request.user: @@ -278,10 +283,14 @@ def has_object_permission(self, request: Request, view, sentry_app: RpcSentryApp if sentry_app.is_published and request.method == "GET": return True - return ensure_scoped_permission(request, scope_map.get(request.method)) + method = request.method + allowed_scopes = scope_map.get(method) if isinstance(method, str) else None + return ensure_scoped_permission(request, allowed_scopes) - def _scopes_for_sentry_app(self, sentry_app): - scope_map: Mapping[str, Collection[str]] + def _scopes_for_sentry_app( + self, sentry_app: RpcSentryApp | SentryApp + ) -> Mapping[str, Sequence[str]]: + scope_map: Mapping[str, Sequence[str]] if sentry_app.is_published: scope_map = self.published_scope_map else: @@ -511,7 +520,9 @@ class SentryAppStatsPermission(SentryPermission): "GET": ("org:read", "org:integrations", "org:write", "org:admin"), } - def has_object_permission(self, request: Request, view, sentry_app: SentryApp | RpcSentryApp): + def has_object_permission( + self, request: Request, view: APIView, sentry_app: SentryApp | RpcSentryApp + ) -> bool: if not hasattr(request, "user") or not request.user: return False @@ -542,7 +553,9 @@ class SentryAppWebhookRequestsPermission(SentryPermission): "GET": ("org:write", "org:admin"), } - def has_object_permission(self, request: Request, view, sentry_app: SentryApp | RpcSentryApp): + def has_object_permission( + self, request: Request, view: APIView, sentry_app: SentryApp | RpcSentryApp + ) -> bool: if not hasattr(request, "user") or not request.user: return False diff --git a/src/sentry/sentry_apps/api/endpoints/sentry_app_avatar.py b/src/sentry/sentry_apps/api/endpoints/sentry_app_avatar.py index 412bb3f35093..f1886011b7d9 100644 --- a/src/sentry/sentry_apps/api/endpoints/sentry_app_avatar.py +++ b/src/sentry/sentry_apps/api/endpoints/sentry_app_avatar.py @@ -5,6 +5,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import control_silo_endpoint from sentry.api.bases.avatar import AvatarMixin +from sentry.db.models.base import Model from sentry.sentry_apps.api.bases.sentryapps import SentryAppBaseEndpoint from sentry.sentry_apps.api.parsers.sentry_app_avatar import SentryAppAvatarParser from sentry.sentry_apps.api.serializers.sentry_app import SentryAppSerializer @@ -12,7 +13,7 @@ @control_silo_endpoint -class SentryAppAvatarEndpoint(AvatarMixin[SentryAppAvatar], SentryAppBaseEndpoint): +class SentryAppAvatarEndpoint(AvatarMixin[SentryAppAvatar, Model], SentryAppBaseEndpoint): owner = ApiOwner.INTEGRATION_PLATFORM publish_status = { "GET": ApiPublishStatus.PRIVATE, diff --git a/src/sentry/sentry_apps/api/parsers/sentry_app.py b/src/sentry/sentry_apps/api/parsers/sentry_app.py index cd3788c74e9e..1f4f5f64692f 100644 --- a/src/sentry/sentry_apps/api/parsers/sentry_app.py +++ b/src/sentry/sentry_apps/api/parsers/sentry_app.py @@ -1,5 +1,6 @@ import logging import re +from collections.abc import Callable, Mapping, Sequence from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field @@ -50,12 +51,14 @@ @extend_schema_field(build_typed_list(OpenApiTypes.STR)) -class ApiScopesField(serializers.Field): - def to_internal_value(self, data): +class ApiScopesField( + serializers.Field[Sequence[str] | None, Sequence[str] | None, Sequence[str], object] +): + def to_internal_value(self, data: Sequence[str] | None) -> Sequence[str] | None: valid_scopes = ApiScopes() if data is None: - return + return None for scope in data: if scope not in valid_scopes: @@ -64,10 +67,12 @@ def to_internal_value(self, data): @extend_schema_field(build_typed_list(OpenApiTypes.STR)) -class EventListField(serializers.Field): - def to_internal_value(self, data): +class EventListField( + serializers.Field[Sequence[str] | None, Sequence[str] | None, Sequence[str], object] +): + def to_internal_value(self, data: Sequence[str] | None) -> Sequence[str] | None: if data is None: - return + return None valid = set(VALID_EVENT_RESOURCES) | set(VALID_EVENTS) if not set(data).issubset(valid): @@ -78,10 +83,10 @@ def to_internal_value(self, data): @extend_schema_field(OpenApiTypes.OBJECT) -class SchemaField(serializers.Field): - def to_internal_value(self, data): +class SchemaField(serializers.Field[object, object, object, object]): + def to_internal_value(self, data: object) -> object: if data is None: - return + return None if data == "" or data == {}: return {} @@ -95,7 +100,7 @@ def to_internal_value(self, data): class URLField(serializers.URLField): - def to_internal_value(self, url): + def to_internal_value(self, url: str) -> str: # The Django URLField doesn't distinguish between different types of # invalid URLs, so do any manual checks here to give the User a better # error message. @@ -204,8 +209,10 @@ def __init__(self, *args, **kwargs): Serializer.__init__(self, *args, **kwargs) # an abstraction to pull fields from attrs if they are available or the sentry_app if not - def get_current_value_wrapper(self, attrs): - def get_current_value(field_name): + def get_current_value_wrapper( + self, attrs: Mapping[str, object] + ) -> Callable[[str], object | None]: + def get_current_value(field_name: str) -> object | None: if field_name in attrs: return attrs[field_name] # params might be passed as camel case but we always store as snake case @@ -217,7 +224,7 @@ def get_current_value(field_name): return get_current_value - def validate_name(self, value): + def validate_name(self, value: str) -> str: max_length = 64 - UUID_CHARS_IN_SLUG - 1 # -1 comes from the - before the UUID bit if len(value) > max_length: raise ValidationError("Cannot exceed %d characters" % max_length) @@ -239,13 +246,13 @@ def validate_name(self, value): return value - def validate_allowedOrigins(self, value): + def validate_allowedOrigins(self, value: list[str]) -> list[str]: for allowed_origin in value: if "*" in allowed_origin: raise ValidationError("'*' not allowed in origin") return value - def validate_webhookHeaders(self, value): + def validate_webhookHeaders(self, value: list[str]) -> list[str]: if len(value) > 20: raise ValidationError("Cannot configure more than 20 custom webhook headers.") seen_names = set() @@ -294,7 +301,7 @@ def validate_webhookHeaders(self, value): seen_names.add(normalized) return value - def validate_scopes(self, value): + def validate_scopes(self, value: Sequence[str] | None) -> Sequence[str] | None: if not value: return value diff --git a/src/sentry/services/filestore/gcs.py b/src/sentry/services/filestore/gcs.py index 672ea2b0d881..d4b581d569dc 100644 --- a/src/sentry/services/filestore/gcs.py +++ b/src/sentry/services/filestore/gcs.py @@ -6,7 +6,9 @@ import random import time from collections.abc import Callable +from datetime import datetime from tempfile import SpooledTemporaryFile +from typing import IO, TYPE_CHECKING, TypeVar from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -32,6 +34,11 @@ from sentry.utils import metrics from sentry.utils.retries import ConditionalRetryPolicy, exponential_delay, sigmoid_delay +if TYPE_CHECKING: + _GoogleCloudFileBase = File[bytes] +else: + _GoogleCloudFileBase = File + # how many times do we want to try if stuff goes wrong GCS_RETRIES = 5 REPLAY_GCS_RETRIES = 125 @@ -54,14 +61,16 @@ # how long are we willing to wait? GCS_TIMEOUT = 6.0 +T = TypeVar("T") + # _client cache is a 3-tuple of project_id, credentials, Client # this is so if any information changes under it, it invalidates # the cache. This scenario is possible since `options` are dynamic -_client: tuple[None, None, None] | tuple[int, object, Client] = None, None, None +_client: tuple[None, None, None] | tuple[str | None, object | None, object] = None, None, None -def try_repeated(func): +def try_repeated(func: Callable[[], T]) -> T: """ Runs a function a few times ignoring errors we see from GCS due to what appears to be network issues. This is a temporary workaround @@ -95,7 +104,7 @@ def try_repeated(func): idx += 1 -def get_client(project_id, credentials): +def get_client(project_id: str | None, credentials: object | None) -> object: global _client if _client[2] is None or (project_id, credentials) != (_client[0], _client[1]): client = Client(project=project_id, credentials=credentials) @@ -103,11 +112,12 @@ def get_client(project_id, credentials): adapter = TimeoutAdapter(timeout=GCS_TIMEOUT) session.mount("http://", adapter) session.mount("https://", adapter) - _client = (project_id, credentials, client) + client_obj: object = client + _client = (project_id, credentials, client_obj) return _client[2] -def clean_name(name): +def clean_name(name: str) -> str: """ Cleans the name so that Windows style paths work """ @@ -127,7 +137,10 @@ def clean_name(name): return clean_name -def safe_join(base, *paths): +def safe_join( + base: str | bytes | os.PathLike[str], + *paths: str | bytes | os.PathLike[str], +) -> str: """ A version of django.utils._os.safe_join for S3 paths. Joins one or more path components to the base path component @@ -137,12 +150,12 @@ def safe_join(base, *paths): Paths outside the base path indicate a possible security sensitive operation. """ - base_path = force_str(base) + base_path: str = force_str(base) base_path = base_path.rstrip("/") - paths = tuple(force_str(p) for p in paths) + normalized_paths: tuple[str, ...] = tuple(force_str(path) for path in paths) final_path = base_path + "/" - for path in paths: + for path in normalized_paths: _final_path = posixpath.normpath(posixpath.join(final_path, path)) # posixpath.normpath() strips the trailing /. Add it back. if path.endswith("/") or _final_path + "/" == final_path: @@ -173,8 +186,8 @@ def _get_download_url(self, *args, **kwargs): return download_url -class GoogleCloudFile(File): - def __init__(self, name, mode, storage): +class GoogleCloudFile(_GoogleCloudFileBase): + def __init__(self, name: str, mode: str, storage: GoogleCloudStorage) -> None: self.name = name self.mime_type = mimetypes.guess_type(name)[0] self._mode = mode @@ -184,7 +197,7 @@ def __init__(self, name, mode, storage): # or metadata at this step. This means we won't actually # know a file doesn't exist until we try to read it. self.blob = FancyBlob(storage.download_url, self.name, storage.bucket) - self._file = None + self._file: IO[bytes] | None = None self._is_dirty = False @property @@ -194,8 +207,8 @@ def size(self): return self.blob.size @property - def file(self): - def _try_download(): + def file(self) -> IO[bytes]: + def _try_download() -> None: assert self._file is not None self.blob.download_to_file(self._file) self._file.seek(0) @@ -211,26 +224,26 @@ def _try_download(): return self._file @file.setter - def file(self, value): + def file(self, value: IO[bytes] | None) -> None: self._file = value - def read(self, num_bytes=None): + def read(self, num_bytes: int | None = None) -> bytes: if "r" not in self._mode: raise AttributeError("File was not opened in read mode.") if num_bytes is None: num_bytes = -1 - return super().read(num_bytes) + return self.file.read(num_bytes) - def write(self, content): + def write(self, content: object) -> int: if "w" not in self._mode: raise AttributeError("File was not opened in write mode.") self._is_dirty = True - return super().write(force_bytes(content)) + return self.file.write(force_bytes(content)) - def close(self): - def _try_upload(): + def close(self) -> None: + def _try_upload() -> None: self.file.seek(0) self.blob.upload_from_file(self.file, content_type=self.mime_type) @@ -242,6 +255,13 @@ def _try_upload(): class GoogleCloudStorage(Storage): + project_id: str | None + bucket_name: str | None + file_name_charset: str + file_overwrite: bool + download_url: str + max_memory_size: int + def __init__( self, project_id=None, @@ -277,7 +297,7 @@ def bucket(self): self._bucket = Bucket(self.client, name=self.bucket_name) return self._bucket - def _normalize_name(self, name): + def _normalize_name(self, name: str) -> str: """ Normalizes the name so that paths like /path/to/ignored/../something.txt and ./file.txt work. Note that clean_name adds ./ to some paths so @@ -285,15 +305,15 @@ def _normalize_name(self, name): """ return safe_join("", name) - def _encode_name(self, name): + def _encode_name(self, name: str) -> str: return smart_str(name, encoding=self.file_name_charset) - def _open(self, name, mode="rb"): + def _open(self, name: str, mode: str = "rb") -> GoogleCloudFile: name = self._normalize_name(clean_name(name)) return GoogleCloudFile(name, mode, self) - def _save(self, name, content): - def _try_upload(): + def _save(self, name: str, content: File[bytes]) -> str: + def _try_upload() -> None: content.seek(0, os.SEEK_SET) file.blob.upload_from_file(content, size=content.size, content_type=file.mime_type) @@ -307,8 +327,8 @@ def _try_upload(): self.try_set(_try_upload) return cleaned_name - def delete(self, name): - def _try_delete(): + def delete(self, name: str) -> None: + def _try_delete() -> None: normalized_name = self._normalize_name(clean_name(name)) self.bucket.delete_blob(self._encode_name(normalized_name)) @@ -317,7 +337,7 @@ def _try_delete(): except NotFound: pass - def exists(self, name): + def exists(self, name: str) -> bool: if not name: # root element aka the bucket try: self.bucket @@ -365,12 +385,12 @@ def size(self, name): blob = self._get_blob(self._encode_name(name)) return blob.size - def modified_time(self, name): + def modified_time(self, name: str) -> datetime: name = self._normalize_name(clean_name(name)) blob = self._get_blob(self._encode_name(name)) return timezone.make_naive(blob.updated) - def get_modified_time(self, name): + def get_modified_time(self, name: str) -> datetime: name = self._normalize_name(clean_name(name)) blob = self._get_blob(self._encode_name(name)) updated = blob.updated @@ -382,7 +402,7 @@ def url(self, name): blob = self._get_blob(self._encode_name(name)) return blob.public_url - def get_available_name(self, name, max_length=None): + def get_available_name(self, name: str, max_length: int | None = None) -> str: if self.file_overwrite: name = clean_name(name) return name @@ -421,7 +441,7 @@ class GoogleCloudStorageWithReplayUploadPolicy(GoogleCloudStorage): # # def try_get(self, callable: Callable[[], None]) -> None: - def create_retry_policy(self): + def create_retry_policy(self) -> ConditionalRetryPolicy: """Retry an action with sigmoid delay for a maximum of five attempts.""" def should_retry(attempt: int, e: Exception) -> bool: diff --git a/src/sentry/services/filestore/s3.py b/src/sentry/services/filestore/s3.py index 42dc8054eccb..e3c2dc1f49c5 100644 --- a/src/sentry/services/filestore/s3.py +++ b/src/sentry/services/filestore/s3.py @@ -42,6 +42,7 @@ import threading from gzip import GzipFile from io import BytesIO +from typing import TYPE_CHECKING from urllib import parse as urlparse from boto3.session import Session @@ -59,6 +60,12 @@ _thread_local_connection = threading.local() +if TYPE_CHECKING: + _S3Boto3StorageFileBase = File[bytes] +else: + _S3Boto3StorageFileBase = File + + def _get_thread_local_session(): try: return _thread_local_connection.session @@ -71,7 +78,7 @@ def resource(*args, **kwargs): return _get_thread_local_session().resource(*args, **kwargs) -def safe_join(base, *paths): +def safe_join(base: str, *paths: str) -> str: """ A version of django.utils._os.safe_join for S3 paths. @@ -104,7 +111,7 @@ def safe_join(base, *paths): return final_path.lstrip("/") -class S3Boto3StorageFile(File): +class S3Boto3StorageFile(_S3Boto3StorageFileBase): """ The default file object used by the S3Boto3Storage backend. @@ -142,7 +149,7 @@ def __init__(self, name, mode, storage, buffer_size=None): # # Force early RAII-style exception if object does not exist # self.obj.load() self._is_dirty = False - self._file = None + self._file: BytesIO | GzipFile | None = None self._multipart = None # 5 MB is the minimum part size (if there is more than one part). # Amazon allows up to 10,000 parts. The default supports uploads @@ -373,18 +380,19 @@ def entries(self): } return self._entries - def _get_access_keys(self): + def _get_access_keys(self) -> tuple[str | None, str | None]: """ Gets the access keys to use when accessing S3. If none are provided to the class in the constructor or in the settings then get them from the environment variables. """ - def lookup_env(names): + def lookup_env(names: list[str]) -> str | None: for name in names: value = os.environ.get(name) if value: return value + return None access_key = self.access_key or lookup_env(self.access_key_names) secret_key = self.secret_key or lookup_env(self.secret_key_names) @@ -417,7 +425,7 @@ def _get_or_create_bucket(self, name): ) return bucket - def _clean_name(self, name): + def _clean_name(self, name: str) -> str: """ Cleans the name so that Windows style paths work """ @@ -432,7 +440,7 @@ def _clean_name(self, name): else: return clean_name - def _normalize_name(self, name): + def _normalize_name(self, name: str) -> str: """ Normalizes the name so that paths like /path/to/ignored/../something.txt work. We check to make sure that the path pointed to is not outside @@ -443,13 +451,13 @@ def _normalize_name(self, name): except ValueError: raise SuspiciousOperation("Attempted access to '%s' denied." % name) - def _encode_name(self, name): + def _encode_name(self, name: str) -> str: return smart_str(name, encoding=self.file_name_charset) - def _decode_name(self, name): + def _decode_name(self, name: str) -> str: return force_str(name, encoding=self.file_name_charset) - def _compress_content(self, content): + def _compress_content(self, content: File[bytes]) -> BytesIO: """Gzip a given string content.""" zbuf = BytesIO() zfile = GzipFile(mode="wb", compresslevel=6, fileobj=zbuf) @@ -463,7 +471,7 @@ def _compress_content(self, content): # so just returning the BytesIO directly return zbuf - def _open(self, name, mode="rb"): + def _open(self, name: str, mode: str = "rb") -> S3Boto3StorageFile: name = self._normalize_name(self._clean_name(name)) try: f = self.file_class(name, mode, self) @@ -583,7 +591,7 @@ def modified_time(self, name): """Returns a naive datetime object containing the last modified time.""" return localtime(self.get_modified_time(name)).replace(tzinfo=None) - def _strip_signing_parameters(self, url): + def _strip_signing_parameters(self, url: str) -> str: # Boto3 does not currently support generating URLs that are unsigned. Instead we # take the signed URLs and strip any querystring params related to signing and expiration. # Note that this may end up with URLs that are still invalid, especially if params are @@ -630,7 +638,7 @@ def url(self, name, parameters=None, expire=None): return url return self._strip_signing_parameters(url) - def get_available_name(self, name, max_length=None): + def get_available_name(self, name: str, max_length: int | None = None) -> str: """Overwrite existing file with the same name.""" if self.file_overwrite: name = self._clean_name(name) diff --git a/src/sentry/snuba/discover.py b/src/sentry/snuba/discover.py index 4e9e12e143c8..1678a0896c3b 100644 --- a/src/sentry/snuba/discover.py +++ b/src/sentry/snuba/discover.py @@ -895,6 +895,7 @@ def histogram_query( key_column = None array_column = None field_names = None + histogram_function: Callable[[str], str | None] if len(fields) > 1: array_column = check_multihistogram_fields(fields) if array_column == "measurements": diff --git a/src/sentry/snuba/metrics/query_builder.py b/src/sentry/snuba/metrics/query_builder.py index 025565831bab..b80f33def1bb 100644 --- a/src/sentry/snuba/metrics/query_builder.py +++ b/src/sentry/snuba/metrics/query_builder.py @@ -3,9 +3,10 @@ from collections.abc import Mapping, Sequence from datetime import datetime, timedelta from enum import Enum -from typing import Any, TypedDict, overload +from typing import Any, TypedDict, cast, overload import sentry_sdk +from django.utils.datastructures import MultiValueDict from snuba_sdk import ( AliasedExpression, Column, @@ -508,11 +509,11 @@ class QueryDefinition: def __init__( self, - projects, - query_params, + projects: Sequence[Project], + query_params: MultiValueDict[str, str], allow_mri: bool = False, - paginator_kwargs: dict | None = None, - ): + paginator_kwargs: Mapping[str, int] | None = None, + ) -> None: self._projects = projects paginator_kwargs = paginator_kwargs or {} @@ -561,7 +562,9 @@ def to_metrics_query(self) -> DeprecatingMetricsQuery: ) @staticmethod - def _parse_orderby(query_params, allow_mri: bool = False): + def _parse_orderby( + query_params: MultiValueDict[str, str], allow_mri: bool = False + ) -> list[MetricsOrderBy] | None: orderbys = query_params.getlist("orderBy", []) if not orderbys: return None @@ -579,19 +582,19 @@ def _parse_orderby(query_params, allow_mri: bool = False): return orderby_list @staticmethod - def _parse_limit(paginator_kwargs) -> Limit | None: + def _parse_limit(paginator_kwargs: Mapping[str, int]) -> Limit | None: if "limit" not in paginator_kwargs: return None return Limit(paginator_kwargs["limit"]) @staticmethod - def _parse_offset(paginator_kwargs) -> Offset | None: + def _parse_offset(paginator_kwargs: Mapping[str, int]) -> Offset | None: if "offset" not in paginator_kwargs: return None return Offset(paginator_kwargs["offset"]) -def get_date_range(params: Mapping) -> tuple[datetime, datetime, int]: +def get_date_range(params: Mapping[str, str | None]) -> tuple[datetime, datetime, int]: """Get start, end, rollup for the given parameters. Apply a similar logic as `sessions_v2.get_constrained_date_range`, @@ -601,7 +604,7 @@ def get_date_range(params: Mapping) -> tuple[datetime, datetime, int]: contrary to the one used in sessions_v2. """ - interval_td = parse_stats_period(params.get("interval", "1h")) + interval_td = parse_stats_period(cast(str, params.get("interval", "1h"))) interval = int(3600 if interval_td is None else interval_td.total_seconds()) start, end = get_date_range_from_params(params, default_stats_period=timedelta(days=1)) diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py index d08128fb93f1..6329d3a6eba0 100644 --- a/src/sentry/tagstore/snuba/backend.py +++ b/src/sentry/tagstore/snuba/backend.py @@ -108,11 +108,11 @@ tag_value_data_transformers = {"first_seen": parse_datetime, "last_seen": parse_datetime} -def is_boolean_key(key): +def is_boolean_key(key: str) -> bool: return key in BOOLEAN_KEYS -def is_fuzzy_numeric_key(key): +def is_fuzzy_numeric_key(key: str) -> bool: return key in FUZZY_NUMERIC_KEYS or snuba.is_measurement(key) or snuba.is_span_op_breakdown(key) @@ -128,7 +128,7 @@ def get_project_list(project_id: int | list[int]) -> list[int]: def _translate_filter_keys( - project_ids, group_ids, environment_ids: Sequence[int] | None + project_ids: Sequence[int], group_ids: Sequence[int], environment_ids: Sequence[int] | None ) -> dict[str, Any]: filter_keys = {"project_id": project_ids} diff --git a/src/sentry/templatetags/sentry_api.py b/src/sentry/templatetags/sentry_api.py index ff88e114e13a..9e91a89fe61a 100644 --- a/src/sentry/templatetags/sentry_api.py +++ b/src/sentry/templatetags/sentry_api.py @@ -1,18 +1,20 @@ from django import template from django.http import HttpRequest +from django.template import Context from sentry.api.serializers.base import serialize as serialize_func from sentry.api.serializers.models.organization import ( OrganizationWithProjectsAndTeamsSerializer, ) from sentry.auth.access import NoAccess, from_user +from sentry.models.organization import Organization from sentry.utils import json register = template.Library() @register.simple_tag(takes_context=True) -def serialize(context, obj): +def serialize(context: Context, obj: object) -> str: if "request" in context: user = context["request"].user else: @@ -22,12 +24,12 @@ def serialize(context, obj): @register.simple_tag -def convert_to_json(obj): +def convert_to_json(obj: object) -> str: return json.dumps_htmlsafe(obj) @register.simple_tag(takes_context=True) -def serialize_detailed_org(context, obj): +def serialize_detailed_org(context: Context, obj: Organization) -> str: if "request" in context: user = context["request"].user access = from_user(user, obj) @@ -35,13 +37,15 @@ def serialize_detailed_org(context, obj): user = None access = NoAccess() - context = serialize_func(obj, user, OrganizationWithProjectsAndTeamsSerializer(), access=access) + serialized_org = serialize_func( + obj, user, OrganizationWithProjectsAndTeamsSerializer(), access=access + ) - return convert_to_json(context) + return convert_to_json(serialized_org) @register.simple_tag -def get_recipient_context(request, escape=False): +def get_recipient_context(request: object, escape: bool = False) -> str: if isinstance(request, HttpRequest): user = getattr(request, "user", None) result = {"ip_address": request.META["REMOTE_ADDR"]} diff --git a/src/sentry/templatetags/sentry_assets.py b/src/sentry/templatetags/sentry_assets.py index 957bd1a1fb7a..41bd01f9e5ed 100644 --- a/src/sentry/templatetags/sentry_assets.py +++ b/src/sentry/templatetags/sentry_assets.py @@ -1,10 +1,12 @@ from __future__ import annotations import re +from typing import Protocol, TypeGuard from django import template from django.conf import settings -from django.template.base import token_kwargs +from django.template import Context +from django.template.base import NodeList, Parser, Token, token_kwargs from sentry import options from sentry.utils.assets import get_asset_url, get_frontend_app_asset_url @@ -16,8 +18,25 @@ register.simple_tag(get_frontend_app_asset_url, name="frontend_app_asset_url") +class ContextLookup(Protocol): + def get(self, key: str) -> object: ... + + +class HasCspNonce(Protocol): + @property + def csp_nonce(self) -> object: ... + + +def _context_value(context: ContextLookup, key: str) -> object: + return context.get(key) + + +def _has_csp_nonce(value: object) -> TypeGuard[HasCspNonce]: + return hasattr(value, "csp_nonce") + + @register.simple_tag -def absolute_asset_url(module, path): +def absolute_asset_url(module: str, path: str) -> str: """ Returns a versioned absolute asset URL (located within Sentry's static files). @@ -29,7 +48,7 @@ def absolute_asset_url(module, path): @register.simple_tag -def crossorigin(): +def crossorigin() -> str: """ Returns an additional crossorigin="anonymous" snippet for use in a " - def _render_attrs(self, context): + def _render_attrs(self, context: Context) -> str: output = [] for k, v in self.attrs.items(): value = self._get_value(v, context) @@ -104,7 +123,7 @@ def _render_attrs(self, context): output = sorted(output) return "".join(output) - def _unwrap_content(self, text): + def _unwrap_content(self, text: str) -> str: matches = re.search(r"]*>([\s\S]*?)])[^>]*>", text, re.IGNORECASE) if matches: return matches.group(1).strip() diff --git a/src/sentry/templatetags/sentry_features.py b/src/sentry/templatetags/sentry_features.py index cf18a2343b2d..9b4706df3214 100644 --- a/src/sentry/templatetags/sentry_features.py +++ b/src/sentry/templatetags/sentry_features.py @@ -1,4 +1,6 @@ from django import template +from django.template import Context +from django.template.base import NodeList, Parser, Token from sentry import features @@ -6,7 +8,7 @@ @register.tag -def feature(parser, token): +def feature(parser: Parser, token: Token) -> "FeatureNode": bits = token.split_contents() if len(bits) < 2: raise template.TemplateSyntaxError( @@ -29,13 +31,15 @@ def feature(parser, token): class FeatureNode(template.Node): - def __init__(self, nodelist_true, nodelist_false, name, params): + def __init__( + self, nodelist_true: NodeList, nodelist_false: NodeList, name: str, params: list[str] + ) -> None: self.nodelist_true = nodelist_true self.nodelist_false = nodelist_false self.name = name self.params = [template.Variable(i) for i in params] - def render(self, context): + def render(self, context: Context) -> str: params = [i.resolve(context) for i in self.params] if "request" in context: user = context["request"].user diff --git a/src/sentry/testutils/helpers/auth_header.py b/src/sentry/testutils/helpers/auth_header.py index 18208bd2e437..7061156e08dc 100644 --- a/src/sentry/testutils/helpers/auth_header.py +++ b/src/sentry/testutils/helpers/auth_header.py @@ -1,7 +1,12 @@ __all__ = ("get_auth_header",) -def get_auth_header(client, api_key=None, secret_key=None, version=None): +def get_auth_header( + client: str, + api_key: str | None = None, + secret_key: str | None = None, + version: str | None = None, +) -> str: if version is None: version = "6" diff --git a/src/sentry/uptime/endpoints/validators.py b/src/sentry/uptime/endpoints/validators.py index ba9cb2323794..e5ffef1a2a97 100644 --- a/src/sentry/uptime/endpoints/validators.py +++ b/src/sentry/uptime/endpoints/validators.py @@ -3,10 +3,12 @@ from typing import Any, override import jsonschema +from django.contrib.auth.models import AnonymousUser from django.db import router from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers from rest_framework.fields import URLField +from rest_framework.request import Request from sentry_kafka_schemas.schema_types.uptime_results_v1 import ( CHECKSTATUS_FAILURE, CHECKSTATUS_SUCCESS, @@ -18,6 +20,7 @@ from sentry.auth.superuser import is_active_superuser from sentry.constants import ObjectStatus from sentry.models.environment import Environment +from sentry.models.organization import Organization from sentry.uptime import checker_api from sentry.uptime.models import ( UptimeSubscription, @@ -43,6 +46,8 @@ update_uptime_subscription, ) from sentry.uptime.types import UptimeMonitorMode +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.utils.audit import create_audit_entry from sentry.utils.db import atomic_transaction from sentry.utils.not_set import NOT_SET @@ -89,8 +94,8 @@ def compute_http_request_size( - method: str, url: str, headers: Sequence[tuple[str, str]], body: str | None -): + method: str, url: str, headers: Sequence[Sequence[str]], body: str | None +) -> int: request_line_size = len(f"{method} {url} HTTP/1.1\r\n") headers_size = sum( len(key) + len(value.encode("utf-8")) + len("\r\n") for key, value in headers @@ -101,7 +106,7 @@ def compute_http_request_size( return request_line_size + headers_size + body_size -def _validate_url(url): +def _validate_url(url: str) -> str: try: check_url_limits(url) except MaxUrlsForDomainReachedException as e: @@ -112,7 +117,7 @@ def _validate_url(url): return url -def _validate_headers(headers): +def _validate_headers(headers: object) -> object: try: jsonschema.validate(headers, HEADERS_LIST_SCHEMA) return headers @@ -120,11 +125,11 @@ def _validate_headers(headers): raise serializers.ValidationError("Expected array of header tuples.") -def _validate_monitor_status(value): +def _validate_monitor_status(value: str) -> int: return MONITOR_STATUSES.get(value, ObjectStatus.ACTIVE) -def _validate_mode(mode, request_context): +def _validate_mode(mode: int, request_context: Request) -> UptimeMonitorMode: if not is_active_superuser(request_context): raise serializers.ValidationError("Only superusers can modify `mode`") try: @@ -135,7 +140,9 @@ def _validate_mode(mode, request_context): ) -def _validate_request_size(method, url, headers, body): +def _validate_request_size( + method: str, url: str, headers: Sequence[Sequence[str]], body: str | None +) -> None: request_size = compute_http_request_size( method, url, @@ -149,8 +156,11 @@ def _validate_request_size(method, url, headers, body): def _validate_check_config( - attrs, uptime_subscription: UptimeSubscription | None, organization, user -): + attrs, + uptime_subscription: UptimeSubscription | None, + organization: Organization, + user: User | RpcUser | AnonymousUser | None, +) -> None: validated_data = attrs if uptime_subscription is not None: validated_data = {} @@ -224,10 +234,10 @@ def validate(self, attrs): _validate_check_config(attrs, None, self.context["organization"], user) return attrs - def validate_url(self, url): + def validate_url(self, url: str) -> str: return _validate_url(url) - def validate_headers(self, headers): + def validate_headers(self, headers: object) -> object: return _validate_headers(headers) def create(self, validated_data): @@ -326,16 +336,16 @@ def validate(self, attrs): return attrs - def validate_url(self, url): + def validate_url(self, url: str) -> str: return _validate_url(url) - def validate_headers(self, headers): + def validate_headers(self, headers: object) -> object: return _validate_headers(headers) - def validate_status(self, value): + def validate_status(self, value: str) -> int: return _validate_monitor_status(value) - def validate_mode(self, mode): + def validate_mode(self, mode: int) -> UptimeMonitorMode: return _validate_mode(mode, self.context["request"]) def create(self, validated_data): @@ -378,7 +388,7 @@ def create(self, validated_data): return detector - def update(self, instance: Detector, data): + def update(self, instance: Detector, data) -> Detector: uptime_subscription = get_uptime_subscription(instance) url = data["url"] if "url" in data else uptime_subscription.url @@ -529,13 +539,13 @@ class Meta: "assertion", ] - def validate_url(self, url): + def validate_url(self, url: str) -> str: return _validate_url(url) - def validate_headers(self, headers): + def validate_headers(self, headers: object) -> object: return _validate_headers(headers) - def validate_mode(self, mode): + def validate_mode(self, mode: int) -> UptimeMonitorMode: return _validate_mode(mode, self.context["request"]) def validate(self, attrs: dict[str, Any]): @@ -763,7 +773,7 @@ def update(self, instance: Detector, validated_data: dict[str, Any]) -> Detector return instance - def update_data_source(self, instance: Detector, data_source: dict[str, Any]): + def update_data_source(self, instance: Detector, data_source: dict[str, Any]) -> Detector: subscription = get_uptime_subscription(instance) update_uptime_subscription( detector=instance, diff --git a/src/sentry/users/api/endpoints/user_avatar.py b/src/sentry/users/api/endpoints/user_avatar.py index 53d2eddf5063..d46dfa80d22a 100644 --- a/src/sentry/users/api/endpoints/user_avatar.py +++ b/src/sentry/users/api/endpoints/user_avatar.py @@ -8,6 +8,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import control_silo_endpoint from sentry.api.bases.avatar import AvatarMixin +from sentry.db.models.base import Model from sentry.users.api.bases.user import UserEndpoint from sentry.users.models.user import User from sentry.users.models.user_avatar import UserAvatar @@ -16,7 +17,7 @@ @control_silo_endpoint -class UserAvatarEndpoint(AvatarMixin[UserAvatar], UserEndpoint): +class UserAvatarEndpoint(AvatarMixin[UserAvatar, Model], UserEndpoint): publish_status = { "GET": ApiPublishStatus.PRIVATE, "PUT": ApiPublishStatus.PRIVATE, diff --git a/src/sentry/users/api/serializers/user.py b/src/sentry/users/api/serializers/user.py index ab398f2223d4..8ee0007a125c 100644 --- a/src/sentry/users/api/serializers/user.py +++ b/src/sentry/users/api/serializers/user.py @@ -78,6 +78,14 @@ class _UserOptions(TypedDict): prefersIssueDetailsStreamlinedUI: bool | None +class _AuthenticatorSerializerResponse(TypedDict): + id: str + type: str + name: str + dateCreated: datetime + dateUsed: datetime | None + + class UserSerializerResponseOptional(TypedDict, total=False): # NOTE: There is a bug here where trying to move these fields to # UserSerializerResponse and using NotRequired. "identities" is marked as @@ -85,7 +93,7 @@ class UserSerializerResponseOptional(TypedDict, total=False): # OrganizationMemberResponse). identities: list[_Identity] avatar: SerializedAvatarFields - authenticators: list[Any] # TODO: find out what type this is + authenticators: list[_AuthenticatorSerializerResponse] canReset2fa: bool @@ -105,7 +113,7 @@ class UserSerializerResponse(UserSerializerResponseOptional): lastActive: datetime | None isSuperuser: bool isStaff: bool - experiments: dict[str, Any] # TODO + experiments: dict[str, str] emails: list[_UserEmails] diff --git a/src/sentry/utils/committers.py b/src/sentry/utils/committers.py index 1e33a15687be..5772eff2c739 100644 --- a/src/sentry/utils/committers.py +++ b/src/sentry/utils/committers.py @@ -12,7 +12,11 @@ from django.db.models import Q from sentry.api.serializers import serialize -from sentry.api.serializers.models.commit import CommitSerializer, get_users_for_commits +from sentry.api.serializers.models.commit import ( + CommitSerializer, + CommitSerializerResponse, + get_users_for_commits, +) from sentry.api.serializers.release_details_types import Author, NonMappableUser from sentry.models.commit import Commit from sentry.models.commitfilechange import CommitFileChange @@ -183,7 +187,7 @@ class AuthorCommits(TypedDict): class AuthorCommitsSerialized(TypedDict): group_owner_id: int author: Author | None - commits: Sequence[MutableMapping[str, Any]] + commits: Sequence[CommitSerializerResponse] class AnnotatedFrame(TypedDict): diff --git a/src/sentry/utils/concurrent.py b/src/sentry/utils/concurrent.py index 1cacad9a9306..12973343b000 100644 --- a/src/sentry/utils/concurrent.py +++ b/src/sentry/utils/concurrent.py @@ -4,14 +4,14 @@ import functools import logging import threading -from collections.abc import Callable +from collections.abc import Callable, Iterator from concurrent.futures import Future, InvalidStateError from concurrent.futures import ThreadPoolExecutor as _ThreadPoolExecutor # noqa: S016 from concurrent.futures._base import FINISHED, RUNNING from contextlib import contextmanager from queue import Full, PriorityQueue from time import time -from typing import Any, NamedTuple +from typing import Any, NamedTuple, Never import sentry_sdk import sentry_sdk.scope @@ -35,11 +35,11 @@ class TimedFuture[T](Future[T]): _condition: threading.Condition _state: str - def __init__(self, *args, **kwargs): + def __init__(self, *args: Never, **kwargs: Never) -> None: self.__timing: list[float | None] = [None, None] # [started, finished/cancelled] super().__init__(*args, **kwargs) - def get_timing(self): + def get_timing(self) -> tuple[float | None, float | None]: """\ Return the timing data for this future in the form ``(started, finished)``. @@ -61,9 +61,9 @@ def get_timing(self): the ``finished`` time (when the future was cancelled) may be before the ``started`` time (when the future was attempted to be executed.) """ - return tuple(self.__timing) + return self.__timing[0], self.__timing[1] - def set_running_or_notify_cancel(self, *args, **kwargs): + def set_running_or_notify_cancel(self, *args: Never, **kwargs: Never) -> bool: result = super().set_running_or_notify_cancel(*args, **kwargs) # This method can only be called once (the second invocation will raise # a ``RuntimeError``) so if we've gotten this far we can be reasonably @@ -71,7 +71,7 @@ def set_running_or_notify_cancel(self, *args, **kwargs): self.__timing[0] = time() return result - def cancel(self, *args, **kwargs): + def cancel(self, *args: Never, **kwargs: Never) -> bool: with self._condition: # Futures can only be marked as cancelled if they are neither # running or finished (we have to duplicate this check that is also @@ -85,7 +85,7 @@ def cancel(self, *args, **kwargs): return super().cancel(*args, **kwargs) @contextmanager - def __set_finished_time_on_success(self): + def __set_finished_time_on_success(self) -> Iterator[None]: prev_value = self.__timing[1] self.__timing[1] = time() try: @@ -94,13 +94,13 @@ def __set_finished_time_on_success(self): self.__timing[1] = prev_value raise - def set_result(self, *args, **kwargs): + def set_result(self, result: T) -> None: with self._condition, self.__set_finished_time_on_success(): - return super().set_result(*args, **kwargs) + return super().set_result(result) - def set_exception(self, *args, **kwargs): + def set_exception(self, exception: BaseException | None) -> None: with self._condition, self.__set_finished_time_on_success(): - return super().set_exception(*args, **kwargs) + return super().set_exception(exception) class Executor: @@ -120,7 +120,7 @@ def submit[T]( callable: Callable[[], T], priority: int = 0, block: bool = True, - timeout=None, + timeout: float | None = None, ) -> TimedFuture[T]: """ Enqueue a task to be executed, returning a ``TimedFuture``. @@ -143,7 +143,9 @@ class SynchronousExecutor(Executor): # TODO: The ``Future`` implementation here could be replaced with a # lock-free future for efficiency. - def submit[T](self, callable: Callable[[], T], *args, **kwargs) -> TimedFuture[T]: + def submit[T]( + self, callable: Callable[[], T], *args: object, **kwargs: object + ) -> TimedFuture[T]: """ Immediately execute a callable, returning a ``TimedFuture``. """ @@ -169,14 +171,14 @@ class ThreadedExecutor(Executor): exits. Any items remaining in the queue at this point may not be executed! """ - def __init__(self, worker_count=1, maxsize=0): + def __init__(self, worker_count: int = 1, maxsize: int = 0) -> None: self.__worker_count = worker_count - self.__workers = set() + self.__workers: set[threading.Thread] = set() self.__started = False self.__queue: PriorityQueue[PriorityTask[Any]] = PriorityQueue(maxsize) self.__lock = threading.Lock() - def __worker(self): + def __worker(self) -> None: queue = self.__queue while True: priority, item = queue.get(True) @@ -193,7 +195,7 @@ def __worker(self): future.set_result(result) queue.task_done() - def start(self): + def start(self) -> None: with self.__lock: if self.__started: return @@ -207,7 +209,11 @@ def start(self): self.__started = True def submit[T]( - self, callable: Callable[[], T], priority=0, block=True, timeout=None + self, + callable: Callable[[], T], + priority: int = 0, + block: bool = True, + timeout: float | None = None, ) -> TimedFuture[T]: """\ Enqueue a task to be executed, returning a ``TimedFuture``. diff --git a/src/sentry/utils/javascript.py b/src/sentry/utils/javascript.py index e8dad32af8d1..4f5e6db35b7c 100644 --- a/src/sentry/utils/javascript.py +++ b/src/sentry/utils/javascript.py @@ -6,12 +6,12 @@ from sentry.utils.safe import get_path if TYPE_CHECKING: - from sentry.services.eventstore.models import Event + from sentry.services.eventstore.models import BaseEvent SOURCE_MAPPING_URL_RE = re.compile(b"//# sourceMappingURL=(.*)$") -def has_sourcemap(event: Event) -> bool: +def has_sourcemap(event: BaseEvent) -> bool: if event.platform not in ("javascript", "node"): return False diff --git a/src/sentry/utils/mockdata/core.py b/src/sentry/utils/mockdata/core.py index 10f59a67d62a..ae4b2008bd21 100644 --- a/src/sentry/utils/mockdata/core.py +++ b/src/sentry/utils/mockdata/core.py @@ -103,7 +103,7 @@ 17. clickhouse-server(DB::executeQuery(std::__cxx11::basic...""" -def make_sentence(words=None): +def make_sentence(words: int | None = None) -> str: if words is None: words = int(random.weibullvariate(8, 3)) return " ".join(random.choice(loremipsum.words) for _ in range(words)) @@ -120,8 +120,8 @@ def create_sample_event(*args, **kwargs): return event -def generate_commit_data(user): - commits = [] +def generate_commit_data(user: User) -> list[Mapping[str, object]]: + commits: list[Mapping[str, object]] = [] for i in range(random.randint(1, 20)): if i == 1: filename = "raven/base.py" @@ -146,7 +146,7 @@ def generate_commit_data(user): return commits -def generate_tombstones(project, user): +def generate_tombstones(project: Project, user: User) -> None: # attempt to create a high enough previous_group_id # that it won't conflict with any group ids prev_group_id = 100000 @@ -660,8 +660,11 @@ def generate_events( def create_mock_transactions( - project_map, load_trends=False, load_performance_issues=False, slow=False -): + project_map: Mapping[str, Project], + load_trends: bool = False, + load_performance_issues: bool = False, + slow: bool = False, +) -> None: backend_project = project_map["Earth"] frontend_project = project_map["Fire"] service_projects = [ @@ -1135,7 +1138,7 @@ def generate_performance_issues(): generate_performance_issues() -def create_mock_attachment(event_id, project): +def create_mock_attachment(event_id: str, project: Project) -> None: attachment_id = str(uuid.uuid4()) file_dir = os.path.dirname(os.path.abspath(__file__)) attachment_path = os.path.join(file_dir, "../../static/sentry/images/favicon.png") @@ -1169,7 +1172,7 @@ def create_mock_attachment(event_id, project): process_individual_attachment(attachment_event, project) -def create_mock_user_feedback(project, has_attachment=True): +def create_mock_user_feedback(project: Project, has_attachment: bool = True) -> None: # Get an existing release for this project instead of creating a new one release = Release.objects.filter(projects=project).first() @@ -1216,13 +1219,13 @@ def create_mock_user_feedback(project, has_attachment=True): def main( - skip_default_setup=False, - num_events=1, - extra_events=False, - load_trends=False, - load_performance_issues=False, - slow=False, -): + skip_default_setup: bool = False, + num_events: int = 1, + extra_events: bool = False, + load_trends: bool = False, + load_performance_issues: bool = False, + slow: bool = False, +) -> None: owner = get_superuser() user = create_user() create_broadcast() diff --git a/src/sentry/utils/samples.py b/src/sentry/utils/samples.py index 9647c1648924..97183288da5f 100644 --- a/src/sentry/utils/samples.py +++ b/src/sentry/utils/samples.py @@ -2,6 +2,7 @@ import os.path import random import time +from collections.abc import Sequence from datetime import datetime, timedelta, timezone from uuid import uuid4 @@ -20,7 +21,7 @@ epoch = datetime.fromtimestamp(0) -def random_normal(mu, sigma, minimum, maximum=None): +def random_normal(mu: float, sigma: float, minimum: float, maximum: float | None = None) -> float: random_value = max(random.normalvariate(mu, sigma), minimum) if maximum is not None: random_value = min(random_value, maximum) @@ -44,7 +45,7 @@ def random_ip(): ) -def random_geo(): +def random_geo() -> dict[str, str]: return random.choice( [ {"country_code": "US", "region": "CA", "city": "San Francisco"}, @@ -54,7 +55,7 @@ def random_geo(): ) -def random_username(): +def random_username() -> str: return random.choice( [ "jess", @@ -109,14 +110,14 @@ def load_data( platform: str, default: str | None = None, sample_name: str | None = None, - timestamp=None, - start_timestamp=None, + timestamp: datetime | None = None, + start_timestamp: datetime | None = None, trace: str | None = None, span_id: str | None = None, - spans=None, - trace_context=None, - fingerprint=None, - event_id=None, + spans: Sequence[dict[str, object]] | None = None, + trace_context: dict[str, object] | None = None, + fingerprint: list[str] | None = None, + event_id: str | None = None, ): # NOTE: Before editing this data, make sure you understand the context # in which its being used. It is NOT only used for local development and @@ -309,7 +310,7 @@ def load_data( return data -def load_console_screenshot(): +def load_console_screenshot() -> str | None: expected_commonpath = os.path.realpath(os.path.join(STATIC_ROOT, "images")) image_path = os.path.join(expected_commonpath, "console-screenshot.png") image_real_path = os.path.realpath(image_path) diff --git a/src/sentry/utils/snuba.py b/src/sentry/utils/snuba.py index b903f0157fc8..e35108645615 100644 --- a/src/sentry/utils/snuba.py +++ b/src/sentry/utils/snuba.py @@ -343,7 +343,7 @@ def log_snuba_info(content): FUNCTION_TO_OPERATOR = {v: k for k, v in OPERATOR_TO_FUNCTION.items()} -def parse_snuba_datetime(value): +def parse_snuba_datetime(value: str) -> datetime: """Parses a datetime value from snuba.""" return parse_datetime(value) @@ -593,7 +593,7 @@ def increment( epoch_naive = datetime(1970, 1, 1, tzinfo=None) -def to_naive_timestamp(value): +def to_naive_timestamp(value: datetime) -> float: """ Convert a time zone aware datetime to a POSIX timestamp (with fractional component.) @@ -606,7 +606,7 @@ def to_start_of_hour(dt: datetime) -> str: return dt.replace(minute=0, second=0, microsecond=0).isoformat() -def get_snuba_column_name(name, dataset=Dataset.Events): +def get_snuba_column_name(name: str, dataset: Dataset = Dataset.Events) -> str: """ Get corresponding Snuba column name from Sentry snuba map, if not found the column is assumed to be a tag. If name is falsy or name is a quoted literal @@ -641,7 +641,7 @@ def get_snuba_column_name(name, dataset=Dataset.Events): return DATASETS[dataset].get(name, default) -def get_function_index(column_expr, depth=0): +def get_function_index(column_expr: object, depth: int = 0) -> int | None: """ If column_expr list contains a function, returns the index of its function name within column_expr (and assumption is that index + 1 is the list of arguments), @@ -678,10 +678,11 @@ def get_function_index(column_expr, depth=0): return None -def get_arrayjoin(column): +def get_arrayjoin(column: str) -> str | None: match = re.match(r"^(exception_stacks|exception_frames|contexts)\..+$", column) if match: return match.groups()[0] + return None def get_organization_id_from_project_ids(project_ids: Sequence[int]) -> int: @@ -1952,7 +1953,7 @@ def resolve_complex_column(col, resolve_func, ignored): } -def get_json_type(snuba_type): +def get_json_type(snuba_type: str | None) -> str: """ Convert Snuba/Clickhouse type to JSON type Default is string @@ -2148,7 +2149,9 @@ def naiveify_datetime(dt: datetime) -> datetime: return dt if not dt.tzinfo else dt.astimezone(timezone.utc).replace(tzinfo=None) -def quantize_time(time, key_hash, duration=300, rounding=ROUND_DOWN): +def quantize_time( + time: datetime, key_hash: int, duration: int = 300, rounding: object = ROUND_DOWN +) -> datetime: """Adds jitter based on the key_hash around start/end times for caching snuba queries Given a time and a key_hash this should result in a timestamp that remains the same for a duration @@ -2189,7 +2192,7 @@ def is_measurement(key): return isinstance(key, str) and MEASUREMENTS_KEY_RE.match(key) -def is_duration_measurement(key): +def is_duration_measurement(key: object) -> bool: return key in [ "measurements.fp", "measurements.fcp", @@ -2208,7 +2211,7 @@ def is_duration_measurement(key): ] -def is_percentage_measurement(key): +def is_percentage_measurement(key: object) -> bool: return key in [ "measurements.frames_slow_rate", "measurements.frames_frozen_rate", @@ -2216,7 +2219,7 @@ def is_percentage_measurement(key): ] -def is_numeric_measurement(key): +def is_numeric_measurement(key: object) -> bool: return key in [ "measurements.cls", "measurements.frames_frozen", @@ -2226,16 +2229,16 @@ def is_numeric_measurement(key): ] -def is_span_op_breakdown(key): +def is_span_op_breakdown(key: object) -> bool: return isinstance(key, str) and get_span_op_breakdown_name(key) is not None -def get_measurement_name(measurement): +def get_measurement_name(measurement: str) -> str | None: match = MEASUREMENTS_KEY_RE.match(measurement) return match.group(1).lower() if match else None -def get_span_op_breakdown_name(breakdown): +def get_span_op_breakdown_name(breakdown: str) -> str | None: match = SPAN_OP_BREAKDOWNS_FIELD_RE.match(breakdown) if match: breakdown_key = match.group(1).lower() @@ -2245,19 +2248,19 @@ def get_span_op_breakdown_name(breakdown): return None -def get_array_column_alias(array_column): +def get_array_column_alias(array_column: str | None) -> str | None: # array column prefix may be aliased differently to the user (i.e. the product) if array_column == "span_op_breakdowns": return "spans" return array_column -def get_span_op_breakdown_key_name(breakdown_key): +def get_span_op_breakdown_key_name(breakdown_key: str) -> str: match = SPAN_OP_BREAKDOWNS_KEY_RE.match(breakdown_key) return match.group(1).lower() if match else breakdown_key -def get_array_column_field(array_column, internal_key): +def get_array_column_field(array_column: str | None, internal_key: str) -> str: if array_column == "span_op_breakdowns": return get_span_op_breakdown_key_name(internal_key) return internal_key diff --git a/src/sentry/web/frontend/error_404.py b/src/sentry/web/frontend/error_404.py index 68948295ad09..e4a8efcde6cf 100644 --- a/src/sentry/web/frontend/error_404.py +++ b/src/sentry/web/frontend/error_404.py @@ -7,7 +7,7 @@ @all_silo_view class Error404View(View): - def dispatch(self, request: HttpRequest, exception=None) -> HttpResponse: + def dispatch(self, request: HttpRequest, exception: Exception | None = None) -> HttpResponse: # HACK: We don't have any use for exception, but in Django 2.0, # signatures for 4XX handler views were changed to include it. return render_to_response("sentry/404.html", status=404, request=request) diff --git a/src/sentry/web/frontend/project_event.py b/src/sentry/web/frontend/project_event.py index aec9622b384f..2f4511c41127 100644 --- a/src/sentry/web/frontend/project_event.py +++ b/src/sentry/web/frontend/project_event.py @@ -1,16 +1,28 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from django.http import Http404, HttpRequest, HttpResponseRedirect from django.urls import reverse from sentry.services import eventstore from sentry.web.frontend.base import ProjectView, cell_silo_view +if TYPE_CHECKING: + from sentry.models.organization import Organization + from sentry.models.project import Project + @cell_silo_view class ProjectEventRedirect(ProjectView): required_scope = "event:read" def handle( - self, request: HttpRequest, organization, project, client_event_id + self, + request: HttpRequest, + organization: Organization, + project: Project, + client_event_id: str, ) -> HttpResponseRedirect: """ Given a client event id and project, redirects to the event page diff --git a/src/social_auth/backends/__init__.py b/src/social_auth/backends/__init__.py index af5e29a7c56e..7478a92e9c30 100644 --- a/src/social_auth/backends/__init__.py +++ b/src/social_auth/backends/__init__.py @@ -25,6 +25,7 @@ from django.utils.crypto import constant_time_compare, get_random_string from requests_oauthlib import OAuth1 +from sentry.users.services.user import RpcUser from sentry.users.services.user.service import user_service from sentry.utils import json from sentry.utils.auth import record_suspended_user_rejection @@ -185,7 +186,7 @@ def tokens(cls, instance): else: return {} - def get_user(self, user_id): + def get_user(self, user_id: int) -> RpcUser | None: """ Return user with given ID from the User model used by this backend. This is called by django.contrib.auth.middleware. @@ -338,13 +339,13 @@ def auth_extra_arguments(self): return extra_arguments @property - def uses_redirect(self): + def uses_redirect(self) -> bool: """Return True if this provider uses redirect url method, otherwise return false.""" return True @classmethod - def enabled(cls): + def enabled(cls) -> bool: """Return backend enabled status, all enabled by default""" return True @@ -394,7 +395,7 @@ def get_key_and_secret(cls): return (setting(cls.SETTINGS_KEY_NAME), setting(cls.SETTINGS_SECRET_NAME)) @classmethod - def enabled(cls): + def enabled(cls) -> bool: """Return backend enabled status by checking basic settings""" return bool(setting(cls.SETTINGS_KEY_NAME) and setting(cls.SETTINGS_SECRET_NAME)) @@ -405,7 +406,7 @@ def get_scope(self): scope = scope + setting(self.SCOPE_VAR_NAME, []) return scope - def get_scope_argument(self): + def get_scope_argument(self) -> dict[str, str]: param = {} scope = self.get_scope() if scope: diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx index 363319f42fa5..fa0663179aa9 100644 --- a/static/app/types/organization.tsx +++ b/static/app/types/organization.tsx @@ -159,7 +159,7 @@ export interface Member { 'sso:linked': boolean; }; id: string; - inviteStatus: 'approved' | 'requested_to_be_invited' | 'requested_to_join'; + inviteStatus: 'approved' | 'requested_to_be_invited' | 'requested_to_join' | null; invite_link: string | null; inviterName: string | null; isOnlyOwner: boolean; diff --git a/tests/sentry/api/endpoints/test_broadcast_index.py b/tests/sentry/api/endpoints/test_broadcast_index.py index 62750538fa94..87d7f52122d4 100644 --- a/tests/sentry/api/endpoints/test_broadcast_index.py +++ b/tests/sentry/api/endpoints/test_broadcast_index.py @@ -19,6 +19,17 @@ def test_simple(self) -> None: assert len(response.data) == 1 assert response.data[0]["id"] == str(broadcast1.id) + def test_admin_shows_creator_email(self) -> None: + Broadcast.objects.create(message="bar", is_active=True, created_by_id=self.user) + + self.add_user_permission(user=self.user, permission="broadcasts.admin") + self.login_as(user=self.user, superuser=True) + + response = self.client.get("/api/0/broadcasts/") + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0]["createdBy"] == self.user.email + def test_superuser_with_all(self) -> None: Broadcast.objects.create(message="bar", is_active=True) Broadcast.objects.create(message="foo", is_active=False) diff --git a/tests/sentry/api/serializers/rest_framework/test_origin.py b/tests/sentry/api/serializers/rest_framework/test_origin.py index 4c71031efed9..d41c1e54bf84 100644 --- a/tests/sentry/api/serializers/rest_framework/test_origin.py +++ b/tests/sentry/api/serializers/rest_framework/test_origin.py @@ -1,10 +1,16 @@ +from typing import TypedDict + from rest_framework import serializers from sentry.api.serializers.rest_framework import OriginField from sentry.testutils.cases import TestCase -class DummySerializer(serializers.Serializer): +class OriginSerializerData(TypedDict): + origin_field: str + + +class DummySerializer(serializers.Serializer[object, OriginSerializerData]): origin_field = OriginField() diff --git a/tests/sentry/api/serializers/rest_framework/test_project.py b/tests/sentry/api/serializers/rest_framework/test_project.py index a2d36cfb9299..f4df89e4d700 100644 --- a/tests/sentry/api/serializers/rest_framework/test_project.py +++ b/tests/sentry/api/serializers/rest_framework/test_project.py @@ -1,7 +1,9 @@ from unittest.mock import Mock +import pytest from rest_framework import serializers +from sentry.api.serializers.rest_framework.list import EmptyListField from sentry.api.serializers.rest_framework.project import ProjectField from sentry.testutils.cases import TestCase @@ -38,3 +40,34 @@ class ProjectSerializer(serializers.Serializer): assert not serializer.is_valid() assert serializer.errors == {"project": ["Invalid project"]} + + +class EmptyListFieldTest(TestCase): + def test_empty_string_is_preserved(self) -> None: + field = EmptyListField(child=serializers.CharField(allow_blank=True)) + + assert field.to_internal_value("") == "" + + def test_default_child_preserves_arbitrary_items(self) -> None: + field = EmptyListField() + + assert field.to_internal_value([1, "value"]) == [1, "value"] + + def test_integer_child_converts_values(self) -> None: + field = EmptyListField(child=serializers.IntegerField()) + + assert field.to_internal_value(["1"]) == [1] + + def test_string_child_validates_values(self) -> None: + field = EmptyListField(child=serializers.CharField()) + + assert field.to_internal_value([1]) == ["1"] + + def test_invalid_values_keep_list_field_validation(self) -> None: + field = EmptyListField(child=serializers.IntegerField()) + + with pytest.raises(serializers.ValidationError): + field.to_internal_value(["invalid"]) + + with pytest.raises(serializers.ValidationError): + field.to_internal_value("invalid") diff --git a/tests/sentry/api/serializers/test_apitoken.py b/tests/sentry/api/serializers/test_apitoken.py index c4a17dbfbc6f..31cf78a9737d 100644 --- a/tests/sentry/api/serializers/test_apitoken.py +++ b/tests/sentry/api/serializers/test_apitoken.py @@ -17,8 +17,8 @@ class TestIncludeTokenFlag(TestApiTokenSerializer): def setUp(self) -> None: super().setUp() attrs = self._serializer.get_attrs(item_list=[self._token], user=self._user) - attrs["application"] = None - self._attrs = attrs + attrs[self._token]["application"] = None + self._attrs = attrs[self._token] def test_when_no_flag_is_passed(self) -> None: serialized_object = self._serializer.serialize( @@ -45,8 +45,8 @@ class TestRefreshTokens(TestApiTokenSerializer): def setUp(self) -> None: super().setUp() attrs = self._serializer.get_attrs(item_list=[self._token], user=self._user) - attrs["application"] = None - self._attrs = attrs + attrs[self._token]["application"] = None + self._attrs = attrs[self._token] def test_no_refresh_token_on_user_token(self) -> None: serialized_object = self._serializer.serialize( @@ -70,9 +70,10 @@ def test_refresh_token_on_non_user_token(self) -> None: class TestLastTokenCharacters(TestApiTokenSerializer): def test_field_is_returned(self) -> None: attrs = self._serializer.get_attrs(item_list=[self._token], user=self._user) - attrs["application"] = None + per_item_attrs = attrs[self._token] + per_item_attrs["application"] = None serialized_object = self._serializer.serialize( - obj=self._token, user=self._user, attrs=attrs + obj=self._token, user=self._user, attrs=per_item_attrs ) assert "tokenLastCharacters" in serialized_object assert serialized_object["tokenLastCharacters"] == self._token.token[-4:] diff --git a/tests/sentry/api/serializers/test_base.py b/tests/sentry/api/serializers/test_base.py index 2bfb443143ca..59f08b286ddb 100644 --- a/tests/sentry/api/serializers/test_base.py +++ b/tests/sentry/api/serializers/test_base.py @@ -1,4 +1,5 @@ from sentry.api.serializers import Serializer, serialize +from sentry.api.serializers.models.actor import ActorSerializer from sentry.testutils.cases import TestCase from sentry.testutils.silo import control_silo_test @@ -78,3 +79,22 @@ def test_child_serializer_failure(self) -> None: result = serialize(foo, serializer=ParentSerializer()) assert result["parent"] == "something" assert result["child"] is None + + +class ActorSerializerTest(TestCase): + def test_user(self) -> None: + user = self.create_user(email="actor@example.com", name="Actor User") + result = serialize(user, self.user, ActorSerializer()) + + assert result == { + "type": "user", + "id": str(user.id), + "name": user.get_display_name(), + "email": user.email, + } + + def test_team(self) -> None: + team = self.create_team(name="Actor Team", slug="actor-team") + result = serialize(team, self.user, ActorSerializer()) + + assert result == {"type": "team", "id": str(team.id), "name": team.slug} diff --git a/tests/sentry/api/serializers/test_organization_member.py b/tests/sentry/api/serializers/test_organization_member.py index d9f82d2bcf52..ef95e0a11723 100644 --- a/tests/sentry/api/serializers/test_organization_member.py +++ b/tests/sentry/api/serializers/test_organization_member.py @@ -49,10 +49,12 @@ def test_user(self) -> None: member = self.create_member( organization=self.org, user_id=user.id, + invite_status=None, ) result = serialize(member, self.user_2, OrganizationMemberSerializer()) assert result["user"]["id"] == str(user.id) assert result["user"]["name"] == "bob" + assert result["inviteStatus"] is None def test_member_with_missing_user_and_no_denormalized_email(self) -> None: # Tests the case where we have no user to resolve, and need to have diff --git a/tests/sentry/api/test_paginator.py b/tests/sentry/api/test_paginator.py index fb63247b905f..e0b9103d206e 100644 --- a/tests/sentry/api/test_paginator.py +++ b/tests/sentry/api/test_paginator.py @@ -1088,7 +1088,7 @@ def data_fn(limit, page_token): assert page.next.has_results is True actual_page_token = PageToken() - actual_page_token.ParseFromString(base64.b64decode(page.next.value.encode("utf-8"))) + actual_page_token.ParseFromString(base64.b64decode(str(page.next.value).encode("utf-8"))) assert actual_page_token == expected_page_token page = paginator.get_result(limit=3, cursor=page.next) diff --git a/tests/sentry/insights/endpoints/test_insights_starred_segment.py b/tests/sentry/insights/endpoints/test_insights_starred_segment.py index ce3ab0dc5f9a..dcffda9cba76 100644 --- a/tests/sentry/insights/endpoints/test_insights_starred_segment.py +++ b/tests/sentry/insights/endpoints/test_insights_starred_segment.py @@ -1,7 +1,9 @@ +from typing import assert_type from urllib.parse import urlencode from django.urls import reverse +from sentry.insights.endpoints.starred_segments import StarSegmentSerializer from sentry.insights.models import InsightsStarredSegment from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase, SnubaTestCase @@ -10,6 +12,15 @@ from sentry.testutils.silo import assume_test_silo_mode +class StarSegmentSerializerTypesTest(APITestCase): + def test_validated_data_types(self) -> None: + data: dict[str, str | int] = {"service_span": "my_service_span", "project_id": 1} + serializer = StarSegmentSerializer(data=data) + assert serializer.is_valid() + assert_type(serializer.validated_data["service_span"], str) + assert_type(serializer.validated_data["project_id"], int) + + class OrganizationStarredServiceSpansTest(APITestCase, SnubaTestCase): feature_name = "organizations:insights-modules-use-eap" diff --git a/tests/sentry/integrations/api/endpoints/test_organization_integration_serverless_functions.py b/tests/sentry/integrations/api/endpoints/test_organization_integration_serverless_functions.py index 7e7b629b6b86..98d6782dd880 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_integration_serverless_functions.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_integration_serverless_functions.py @@ -42,7 +42,9 @@ def get_response(self, **kwargs): @property def sentry_dsn(self): - return ProjectKey.get_default(project=self.project).get_dsn(public=True) + project_key = ProjectKey.get_default(project=self.project) + assert project_key is not None + return project_key.get_dsn(public=True) def set_up_response_mocks(self, get_function_response, update_function_configuration_kwargs): responses.add( diff --git a/tests/sentry/integrations/aws_lambda/test_integration.py b/tests/sentry/integrations/aws_lambda/test_integration.py index ce86024faa8b..9b8bf63c1064 100644 --- a/tests/sentry/integrations/aws_lambda/test_integration.py +++ b/tests/sentry/integrations/aws_lambda/test_integration.py @@ -200,7 +200,9 @@ def test_full_api_pipeline_success( ] with assume_test_silo_mode(SiloMode.CELL): - sentry_project_dsn = ProjectKey.get_default(project=self.projectA).get_dsn(public=True) + project_key = ProjectKey.get_default(project=self.projectA) + assert project_key is not None + sentry_project_dsn = project_key.get_dsn(public=True) self._initialize_pipeline() self._advance_step({"projectId": self.projectA.id}) diff --git a/tests/sentry/lang/native/test_processing.py b/tests/sentry/lang/native/test_processing.py index 27dc23dec259..fca92ceb2aa7 100644 --- a/tests/sentry/lang/native/test_processing.py +++ b/tests/sentry/lang/native/test_processing.py @@ -30,6 +30,7 @@ _merge_image, get_frames_for_symbolication, get_native_symbolication_functions, + get_required_attachment_types, process_native_stacktraces, ) from sentry.lang.native.symbolicator import SymbolicatorFunction @@ -59,6 +60,19 @@ } +def test_get_required_attachment_types() -> None: + minidump_data: dict[str, object] = {"exception": {"values": [MINIDUMP_PLACEHOLDER]}} + assert get_required_attachment_types(minidump_data) == {"event.minidump"} + + applecrashreport_data: dict[str, object] = { + "exception": {"values": [APPLECRASHREPORT_PLACEHOLDER]} + } + assert get_required_attachment_types(applecrashreport_data) == {"event.applecrashreport"} + + ordinary_data: dict[str, object] = {"platform": "python"} + assert not get_required_attachment_types(ordinary_data) + + @pytest.mark.parametrize( ("exceptions", "expected_functions"), [ diff --git a/tests/sentry/middleware/test_health.py b/tests/sentry/middleware/test_health.py index f8e5a3c16c0f..a998a805fb5b 100644 --- a/tests/sentry/middleware/test_health.py +++ b/tests/sentry/middleware/test_health.py @@ -27,6 +27,7 @@ def test_other_url(self, check_all: MagicMock) -> None: def test_basic_health(self, check_all: MagicMock) -> None: req = self.factory.get("/_health/") resp = self.middleware.process_request(req) + assert resp is not None assert resp.status_code == 200, resp assert check_all.call_count == 0 @@ -35,6 +36,7 @@ def test_full_health_ok(self, check_all: MagicMock) -> None: check_all.return_value = {object(): []} req = self.factory.get("/_health/?full") resp = self.middleware.process_request(req) + assert resp is not None assert resp.status_code == 200, resp body = json.loads(resp.content) assert "problems" in body @@ -46,6 +48,7 @@ def test_full_health_bad(self, check_all: MagicMock) -> None: check_all.return_value = {object(): [Problem("the system is down")]} req = self.factory.get("/_health/?full") resp = self.middleware.process_request(req) + assert resp is not None assert resp.status_code == 500, resp body = json.loads(resp.content) assert "problems" in body diff --git a/tests/sentry/models/test_organizationmember.py b/tests/sentry/models/test_organizationmember.py index f438302d75bc..8c14a0603aa3 100644 --- a/tests/sentry/models/test_organizationmember.py +++ b/tests/sentry/models/test_organizationmember.py @@ -1,4 +1,5 @@ from datetime import timedelta +from typing import cast from unittest.mock import MagicMock, patch import pytest @@ -83,6 +84,7 @@ def test_get_invite_link_with_referrer(self) -> None: member = OrganizationMember(id=1, organization=self.organization, email="foo@example.com") link = member.get_invite_link(referrer="test_referrer") + assert link is not None assert "?referrer=test_referrer" in link def test_send_invite_email(self) -> None: @@ -210,9 +212,9 @@ def test_set_user(self) -> None: assert member.is_pending is False assert member.token_expires_at is None - assert member.token is None + assert cast(str | None, member.token) is None + member = OrganizationMember.objects.get(pk=member.pk) assert member.email is None - member.refresh_from_db() self.assert_org_member_mapping(org_member=member) def test_regenerate_token(self) -> None: diff --git a/tests/sentry/models/test_release.py b/tests/sentry/models/test_release.py index 68d5c76eaaa7..1c16f1aaee74 100644 --- a/tests/sentry/models/test_release.py +++ b/tests/sentry/models/test_release.py @@ -171,7 +171,7 @@ def test_equal_dates_and_project_scope(self) -> None: "fake_package@1.0.0.0-beta+exp.sha.5114f85", # major, minor, patch, revision, pre-release, and build ], ) -def test_version_is_semver_valid(release_version) -> None: +def test_version_is_semver_valid(release_version: str) -> None: assert Release.is_semver_version(release_version) is True @@ -198,7 +198,7 @@ def test_version_is_semver_valid(release_version) -> None: "package@1.0.0-alpha.1+1..2", # Empty build component ], ) -def test_version_is_semver_invalid(release_version) -> None: +def test_version_is_semver_invalid(release_version: str) -> None: assert Release.is_semver_version(release_version) is False diff --git a/tests/sentry/models/test_releasefile.py b/tests/sentry/models/test_releasefile.py index 9f99d5207d13..a8c8e11fe503 100644 --- a/tests/sentry/models/test_releasefile.py +++ b/tests/sentry/models/test_releasefile.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from datetime import datetime, timezone from io import BytesIO from threading import Thread @@ -202,10 +203,13 @@ def test_same_sha(self) -> None: class ArtifactIndexGuardTestCase(TransactionTestCase): tick = 0.1 # seconds - def _create_update_fn(self, initial_delay, locked_delay, files, create): - def f(): + def _create_update_fn( + self, initial_delay: float, locked_delay: float, files: dict[str, object], create: bool + ) -> Callable[[], None]: + def f() -> None: sleep(initial_delay * self.tick) with _ArtifactIndexGuard(self.release, None).writable_data(create=create) as data: + assert data is not None sleep(locked_delay * self.tick) data.update_files(files) @@ -254,6 +258,7 @@ def test_lock_existing(self) -> None: dist = None with _ArtifactIndexGuard(release, dist).writable_data(create=True) as data: + assert data is not None data.update_files({"0": 0}) update1 = self._create_update_fn(0, 2, {"foo": "bar"}, create=False) diff --git a/tests/sentry/runner/test_initializer.py b/tests/sentry/runner/test_initializer.py index 3fd8b59c9b4d..e58723438fc3 100644 --- a/tests/sentry/runner/test_initializer.py +++ b/tests/sentry/runner/test_initializer.py @@ -1,8 +1,10 @@ import copy import types +from pathlib import Path from unittest.mock import patch import pytest +from _pytest._py.path import LocalPath from django.core.cache import caches from django.test import override_settings @@ -23,7 +25,7 @@ @pytest.fixture -def settings(): +def settings() -> types.SimpleNamespace: return types.SimpleNamespace( TIME_ZONE="UTC", ALLOWED_HOSTS=[], @@ -40,8 +42,8 @@ def settings(): @pytest.fixture -def config_yml(tmpdir): - return tmpdir.join("config.yml") +def config_yml(tmpdir: LocalPath) -> LocalPath: + return tmpdir.join(Path("config.yml")) def _assert_settings_warnings(warninfo, expected): diff --git a/tests/sentry/scm/unit/private/test_ipc.py b/tests/sentry/scm/unit/private/test_ipc.py index 326da4596a4c..fcd73e7173fa 100644 --- a/tests/sentry/scm/unit/private/test_ipc.py +++ b/tests/sentry/scm/unit/private/test_ipc.py @@ -41,6 +41,8 @@ CheckSuiteEvent, CommentEvent, EventType, + EventTypeHint, + HybridCloudSilo, PullRequestEvent, PullRequestReviewEvent, SubscriptionEvent, @@ -53,7 +55,7 @@ def test_exec_listener() -> None: """ count = 0 - def record_count(a, b, c): + def record_count(a: str, b: int, c: dict[str, str]) -> None: nonlocal count count += 1 @@ -65,10 +67,10 @@ def test_exec_listener_missing_listener() -> None: """ Assert the developers listener was removed prior to channel drain. """ - key = None - tags = None + key: str | None = None + tags: dict[str, str] | None = None - def record_count(k, a, t): + def record_count(k: str, a: int, t: dict[str, str]) -> None: nonlocal key, tags key = k tags = t @@ -82,13 +84,13 @@ def test_exec_listener_failed() -> None: """ Assert the developers listener failed. """ - key = None - tags = None + key: str | None = None + tags: dict[str, str] | None = None - def listener(e): + def listener(e: int) -> None: raise Exception("whatever") - def record_count(k, a, t): + def record_count(k: str, a: int, t: dict[str, str]) -> None: nonlocal key, tags key = k tags = t @@ -100,12 +102,12 @@ def record_count(k, a, t): def test_run_check_run_listener() -> None: - event = None + event: CheckRunEvent | None = None scm = SourceCodeManagerEventStream() @scm.listen_for("check_run") - def call_me_maybe(e): + def call_me_maybe(e: CheckRunEvent) -> None: nonlocal event event = e @@ -138,7 +140,7 @@ def test_run_comment_listener() -> None: scm = SourceCodeManagerEventStream() @scm.listen_for("comment") - def comment_handler(e): + def comment_handler(e: CommentEvent) -> None: nonlocal event event = e @@ -176,12 +178,12 @@ def test_run_pull_request_listener() -> None: """ Test that pull request events are properly deserialized and routed to PR listeners. """ - event = None + event: PullRequestEvent | None = None scm = SourceCodeManagerEventStream() @scm.listen_for("pull_request") - def pr_handler(e): + def pr_handler(e: PullRequestEvent) -> None: nonlocal event event = e @@ -221,12 +223,12 @@ def pr_handler(e): def test_run_check_suite_listener() -> None: - event = None + event: CheckSuiteEvent | None = None scm = SourceCodeManagerEventStream() @scm.listen_for("check_suite") - def call_me_maybe(e): + def call_me_maybe(e: CheckSuiteEvent) -> None: nonlocal event event = e @@ -256,12 +258,12 @@ def call_me_maybe(e): def test_run_pull_request_review_listener() -> None: - event = None + event: PullRequestReviewEvent | None = None scm = SourceCodeManagerEventStream() @scm.listen_for("pull_request_review") - def review_handler(e): + def review_handler(e: PullRequestReviewEvent) -> None: nonlocal event event = e @@ -295,14 +297,14 @@ def test_run_listener_metrics_recorded() -> None: """ Test that success metrics and timing metrics are properly recorded. """ - distributions = None - metrics = [] - timers = [] + distributions: tuple[str, int, dict[str, str], str] | None = None + metrics: list[tuple[str, int, dict[str, str]]] = [] + timers: list[tuple[str, float, dict[str, str]]] = [] scm = SourceCodeManagerEventStream() @scm.listen_for("check_run") - def handler(e): + def handler(e: CheckRunEvent) -> None: pass check_run_event = CheckRunEventParser( @@ -312,14 +314,14 @@ def handler(e): ) message = msgspec.json.encode(check_run_event).decode("utf-8") - def record_count(key, amount, tags): + def record_count(key: str, amount: int, tags: dict[str, str]) -> None: metrics.append((key, amount, tags)) - def record_distribution(key, amount, tags, unit): + def record_distribution(key: str, amount: int, tags: dict[str, str], unit: str) -> None: nonlocal distributions distributions = (key, amount, tags, unit) - def record_timer(key, amount, tags): + def record_timer(key: str, amount: float, tags: dict[str, str]) -> None: timers.append((key, amount, tags)) run_listener( @@ -349,9 +351,9 @@ def test_run_listener_not_found() -> None: """ Test that calling a non-existent listener doesn't raise an exception. """ - metrics = [] + metrics: list[tuple[str, int, dict[str, str]]] = [] - def record_count(key, amount, tags): + def record_count(key: str, amount: int, tags: dict[str, str]) -> None: metrics.append((key, amount, tags)) check_run_event = CheckRunEventParser( @@ -383,15 +385,15 @@ def test_run_listener_exception_propagates() -> None: """ Test that exceptions from listeners are properly propagated and metrics recorded. """ - metrics = [] + metrics: list[tuple[str, int, dict[str, str]]] = [] scm = SourceCodeManagerEventStream() @scm.listen_for("check_run") - def failing_handler(e): + def failing_handler(e: CheckRunEvent) -> None: raise ValueError("Something went wrong") - def record_count(key, amount, tags): + def record_count(key: str, amount: int, tags: dict[str, str]) -> None: metrics.append((key, amount, tags)) check_run_event = CheckRunEventParser( @@ -420,9 +422,9 @@ def record_count(key, amount, tags): def test_run_listener_malformed_input() -> None: - metrics = [] + metrics: list[tuple[str, int, dict[str, str]]] = [] - def record_count(a, b, c): + def record_count(a: str, b: int, c: dict[str, str]) -> None: metrics.append((a, b, c)) with pytest.raises(msgspec.MsgspecError): @@ -896,9 +898,11 @@ def test_produce_to_listeners_check_run() -> None: """ Test that produce_to_listeners correctly routes check run events to registered listeners. """ - produced_messages = [] + produced_messages: list[tuple[str, EventTypeHint, str, HybridCloudSilo]] = [] - def mock_produce(message, event_type_hint, listener_name, silo): + def mock_produce( + message: str, event_type_hint: EventTypeHint, listener_name: str, silo: HybridCloudSilo + ) -> None: produced_messages.append((message, event_type_hint, listener_name, silo)) subscription_event: SubscriptionEvent = { @@ -910,7 +914,7 @@ def mock_produce(message, event_type_hint, listener_name, silo): "type": "github", } - def mock_deserialize(event): + def mock_deserialize(event: SubscriptionEvent) -> EventType: return CheckRunEvent( action="completed", check_run={"external_id": "1", "html_url": "url"}, @@ -920,11 +924,11 @@ def mock_deserialize(event): mock_stream = SourceCodeManagerEventStream() @mock_stream.listen_for("check_run") - def listener_one(e): + def listener_one(e: CheckRunEvent) -> None: pass @mock_stream.listen_for("check_run") - def listener_two(e): + def listener_two(e: CheckRunEvent) -> None: pass with mock.patch("sentry.scm.private.ipc.deserialize_raw_event", mock_deserialize): @@ -941,9 +945,11 @@ def test_produce_to_listeners_comment() -> None: """ Test that produce_to_listeners correctly routes comment events to registered listeners. """ - produced_messages = [] + produced_messages: list[tuple[str, EventTypeHint, str, HybridCloudSilo]] = [] - def mock_produce(message, event_type_hint, listener_name, silo): + def mock_produce( + message: str, event_type_hint: EventTypeHint, listener_name: str, silo: HybridCloudSilo + ) -> None: produced_messages.append((message, event_type_hint, listener_name, silo)) subscription_event: SubscriptionEvent = { @@ -955,7 +961,7 @@ def mock_produce(message, event_type_hint, listener_name, silo): "type": "github", } - def mock_deserialize(event): + def mock_deserialize(event: SubscriptionEvent) -> EventType: return CommentEvent( action="created", comment_type="issue", @@ -966,7 +972,7 @@ def mock_deserialize(event): mock_stream = SourceCodeManagerEventStream() @mock_stream.listen_for("comment") - def comment_listener(e): + def comment_listener(e: CommentEvent) -> None: pass with mock.patch("sentry.scm.private.ipc.deserialize_raw_event", mock_deserialize): @@ -981,9 +987,11 @@ def test_produce_to_listeners_pull_request() -> None: """ Test that produce_to_listeners correctly routes PR events to registered listeners. """ - produced_messages = [] + produced_messages: list[tuple[str, EventTypeHint, str, HybridCloudSilo]] = [] - def mock_produce(message, event_type_hint, listener_name, silo): + def mock_produce( + message: str, event_type_hint: EventTypeHint, listener_name: str, silo: HybridCloudSilo + ) -> None: produced_messages.append((message, event_type_hint, listener_name, silo)) subscription_event: SubscriptionEvent = { @@ -995,7 +1003,7 @@ def mock_produce(message, event_type_hint, listener_name, silo): "type": "github", } - def mock_deserialize(event): + def mock_deserialize(event: SubscriptionEvent) -> EventType: return PullRequestEvent( action="opened", pull_request={ @@ -1015,7 +1023,7 @@ def mock_deserialize(event): mock_stream = SourceCodeManagerEventStream() @mock_stream.listen_for("pull_request") - def pr_listener(e): + def pr_listener(e: PullRequestEvent) -> None: pass with mock.patch("sentry.scm.private.ipc.deserialize_raw_event", mock_deserialize): @@ -1027,9 +1035,11 @@ def pr_listener(e): def test_produce_to_listeners_check_suite() -> None: - produced_messages = [] + produced_messages: list[tuple[str, EventTypeHint, str, HybridCloudSilo]] = [] - def mock_produce(message, event_type_hint, listener_name, silo): + def mock_produce( + message: str, event_type_hint: EventTypeHint, listener_name: str, silo: HybridCloudSilo + ) -> None: produced_messages.append((message, event_type_hint, listener_name, silo)) subscription_event: SubscriptionEvent = { @@ -1041,7 +1051,7 @@ def mock_produce(message, event_type_hint, listener_name, silo): "type": "github", } - def mock_deserialize(event): + def mock_deserialize(event: SubscriptionEvent) -> EventType: return CheckSuiteEvent( action="completed", check_suite={ @@ -1057,7 +1067,7 @@ def mock_deserialize(event): mock_stream = SourceCodeManagerEventStream() @mock_stream.listen_for("check_suite") - def cs_listener(e): + def cs_listener(e: CheckSuiteEvent) -> None: pass with mock.patch("sentry.scm.private.ipc.deserialize_raw_event", mock_deserialize): @@ -1069,9 +1079,11 @@ def cs_listener(e): def test_produce_to_listeners_pull_request_review() -> None: - produced_messages = [] + produced_messages: list[tuple[str, EventTypeHint, str, HybridCloudSilo]] = [] - def mock_produce(message, event_type_hint, listener_name, silo): + def mock_produce( + message: str, event_type_hint: EventTypeHint, listener_name: str, silo: HybridCloudSilo + ) -> None: produced_messages.append((message, event_type_hint, listener_name, silo)) subscription_event: SubscriptionEvent = { @@ -1083,7 +1095,7 @@ def mock_produce(message, event_type_hint, listener_name, silo): "type": "github", } - def mock_deserialize(event): + def mock_deserialize(event: SubscriptionEvent) -> EventType: return PullRequestReviewEvent( action="submitted", pull_request_review={"id": "1", "state": "approved", "pull_request_id": "42"}, @@ -1095,7 +1107,7 @@ def mock_deserialize(event): mock_stream = SourceCodeManagerEventStream() @mock_stream.listen_for("pull_request_review") - def review_listener(e): + def review_listener(e: PullRequestReviewEvent) -> None: pass with mock.patch("sentry.scm.private.ipc.deserialize_raw_event", mock_deserialize): @@ -1110,9 +1122,11 @@ def test_produce_to_listeners_returns_none_for_unsupported_events() -> None: """ Test that produce_to_listeners returns None when deserialize_raw_event returns None. """ - produced_messages = [] + produced_messages: list[tuple[str, EventTypeHint, str, HybridCloudSilo]] = [] - def mock_produce(message, event_type_hint, listener_name, silo): + def mock_produce( + message: str, event_type_hint: EventTypeHint, listener_name: str, silo: HybridCloudSilo + ) -> None: produced_messages.append((message, event_type_hint, listener_name, silo)) subscription_event: SubscriptionEvent = { diff --git a/tests/sentry/stacktraces/test_functions.py b/tests/sentry/stacktraces/test_functions.py index 38b01a21cc0b..026984d9c4b2 100644 --- a/tests/sentry/stacktraces/test_functions.py +++ b/tests/sentry/stacktraces/test_functions.py @@ -355,4 +355,6 @@ def test_trim_function_name_cocoa() -> None: ], ) def test_get_source_link_for_frame(input, output) -> None: - assert get_source_link_for_frame(Frame.to_python(input)) == output + frame = Frame.to_python(input) + assert frame is not None + assert get_source_link_for_frame(frame) == output diff --git a/tests/sentry/utils/test_cursored_scheduler.py b/tests/sentry/utils/test_cursored_scheduler.py index a03814235a92..12baa1f58818 100644 --- a/tests/sentry/utils/test_cursored_scheduler.py +++ b/tests/sentry/utils/test_cursored_scheduler.py @@ -1,9 +1,11 @@ import time +from collections.abc import Sequence from datetime import timedelta from unittest.mock import MagicMock, patch import pytest from django.core.cache import cache +from django.db.models import QuerySet from django.test import override_settings from sentry.constants import ObjectStatus @@ -43,7 +45,7 @@ @control_silo_test @override_settings(TASKWORKER_SCHEDULES=TEST_SCHEDULES) class CursoredSchedulerTest(TestCase): - def setUp(self): + def setUp(self) -> None: self.mock_task = MagicMock() self.cache_key = f"{CURSOR_CACHE_KEY_PREFIX}:test_scheduler" self.batch_size_key = f"{BATCH_SIZE_CACHE_KEY_PREFIX}:test_scheduler" @@ -59,7 +61,7 @@ def setUp(self): _oi_counter = 0 - def _create_org_integrations(self, count): + def _create_org_integrations(self, count: int) -> list[OrganizationIntegration]: """Create real OrganizationIntegration rows for testing.""" integrations = [] for _ in range(count): @@ -76,15 +78,15 @@ def _create_org_integrations(self, count): integrations.append(oi) return integrations - def _make_next_cycle_due(self, scheduler): + def _make_next_cycle_due(self, scheduler: CursoredScheduler[OrganizationIntegration]) -> None: cache.set(self.next_cycle_start_key, time.time() - 1, scheduler.cache_ttl) def _make_scheduler( self, - queryset=None, - cycle_duration=timedelta(minutes=3), - schedule_key="test-scheduler-beat", - ): + queryset: QuerySet[OrganizationIntegration] | None = None, + cycle_duration: timedelta = timedelta(minutes=3), + schedule_key: str = "test-scheduler-beat", + ) -> CursoredScheduler[OrganizationIntegration]: """ Default: 3-minute cycle with 1-minute tick interval (from schedule) = 3 ticks per cycle. Batch size = ceil(total_rows / 3). @@ -102,7 +104,7 @@ def _make_scheduler( cycle_duration=cycle_duration, ) - def test_calculates_batch_size(self): + def test_calculates_batch_size(self) -> None: """With 30 items, 3-min cycle, 1-min tick → batch_size=10.""" ois = self._create_org_integrations(30) scheduler = self._make_scheduler() @@ -113,7 +115,7 @@ def test_calculates_batch_size(self): dispatched_pks = [c.args[0] for c in self.mock_task.delay.call_args_list] assert dispatched_pks == [oi.pk for oi in ois[:10]] - def test_advances_cursor_between_ticks(self): + def test_advances_cursor_between_ticks(self) -> None: ois = self._create_org_integrations(30) scheduler = self._make_scheduler() @@ -127,7 +129,7 @@ def test_advances_cursor_between_ticks(self): dispatched_pks = [c.args[0] for c in self.mock_task.delay.call_args_list] assert dispatched_pks == [oi.pk for oi in ois[10:20]] - def test_completes_full_cycle(self): + def test_completes_full_cycle(self) -> None: ois = self._create_org_integrations(30) scheduler = self._make_scheduler() @@ -139,7 +141,7 @@ def test_completes_full_cycle(self): dispatched_pks = [c.args[0] for c in self.mock_task.delay.call_args_list] assert dispatched_pks == [oi.pk for oi in ois] - def test_resets_after_cycle_completes(self): + def test_resets_after_cycle_completes(self) -> None: ois = self._create_org_integrations(30) scheduler = self._make_scheduler() @@ -157,7 +159,7 @@ def test_resets_after_cycle_completes(self): dispatched_pks = [c.args[0] for c in self.mock_task.delay.call_args_list] assert dispatched_pks == [oi.pk for oi in ois[:10]] - def test_empty_queryset(self): + def test_empty_queryset(self) -> None: scheduler = self._make_scheduler() has_more = scheduler.tick() @@ -165,7 +167,7 @@ def test_empty_queryset(self): assert has_more is False self.mock_task.delay.assert_not_called() - def test_empty_cycle_is_not_reinitialized_every_tick(self): + def test_empty_cycle_is_not_reinitialized_every_tick(self) -> None: """An empty queryset runs the cycle-start query once, not on every tick.""" scheduler = self._make_scheduler() @@ -177,7 +179,7 @@ def test_empty_cycle_is_not_reinitialized_every_tick(self): assert prevalidated_pks.call_count == 1 - def test_empty_cycle_is_retried_after_the_cycle_duration(self): + def test_empty_cycle_is_retried_after_the_cycle_duration(self) -> None: scheduler = self._make_scheduler() scheduler.tick() @@ -187,7 +189,7 @@ def test_empty_cycle_is_retried_after_the_cycle_duration(self): assert scheduler.tick() is True assert self.mock_task.delay.call_count == 1 - def test_small_set_is_not_redispatched_before_the_cycle_elapses(self): + def test_small_set_is_not_redispatched_before_the_cycle_elapses(self) -> None: """ One item over a 3-tick cycle has batch_size 1 and finishes on the first tick. The next cycle waits for the rest of cycle_duration, so the item is dispatched @@ -206,7 +208,7 @@ def test_small_set_is_not_redispatched_before_the_cycle_elapses(self): assert scheduler.tick() is True assert self.mock_task.delay.call_count == 2 - def test_next_cycle_start_is_measured_from_the_cycle_start(self): + def test_next_cycle_start_is_measured_from_the_cycle_start(self) -> None: """A cycle that finishes early does not push the next start out past cycle_duration.""" self._create_org_integrations(1) scheduler = self._make_scheduler(cycle_duration=timedelta(minutes=3)) @@ -216,7 +218,7 @@ def test_next_cycle_start_is_measured_from_the_cycle_start(self): cycle_start = float(cache.get(f"{CYCLE_START_CACHE_KEY_PREFIX}:test_scheduler")) assert float(cache.get(self.next_cycle_start_key)) == cycle_start + 180 - def test_batch_size_cached_across_ticks(self): + def test_batch_size_cached_across_ticks(self) -> None: """Batch size is calculated once at cycle start, not every tick.""" self._create_org_integrations(30) scheduler = self._make_scheduler() @@ -227,7 +229,7 @@ def test_batch_size_cached_across_ticks(self): assert cached_size is not None assert int(cached_size) == 10 - def test_batch_size_recalculated_on_new_cycle(self): + def test_batch_size_recalculated_on_new_cycle(self) -> None: """After cycle completes and resets, batch size is recalculated.""" self._create_org_integrations(30) scheduler = self._make_scheduler() @@ -247,7 +249,7 @@ def test_batch_size_recalculated_on_new_cycle(self): scheduler.tick() assert self.mock_task.delay.call_count == 20 - def test_reads_tick_interval_from_schedule(self): + def test_reads_tick_interval_from_schedule(self) -> None: """Tick interval is read from TASKWORKER_SCHEDULES, not passed directly.""" self._create_org_integrations(30) @@ -260,7 +262,7 @@ def test_reads_tick_interval_from_schedule(self): scheduler.tick() assert self.mock_task.delay.call_count == 30 - def test_cursor_survives_across_instances(self): + def test_cursor_survives_across_instances(self) -> None: ois = self._create_org_integrations(30) qs = OrganizationIntegration.objects.filter( integration__provider="github", @@ -289,7 +291,7 @@ def test_cursor_survives_across_instances(self): dispatched_pks = [c.args[0] for c in self.mock_task.delay.call_args_list] assert dispatched_pks == [oi.pk for oi in ois[10:20]] - def test_different_names_are_independent(self): + def test_different_names_are_independent(self) -> None: self._create_org_integrations(30) qs = OrganizationIntegration.objects.filter( integration__provider="github", @@ -321,7 +323,7 @@ def test_different_names_are_independent(self): assert task_a.delay.call_count == 30 assert task_b.delay.call_count == 15 - def test_dispatches_pks_not_objects(self): + def test_dispatches_pks_not_objects(self) -> None: self._create_org_integrations(30) scheduler = self._make_scheduler() @@ -330,7 +332,7 @@ def test_dispatches_pks_not_objects(self): for c in self.mock_task.delay.call_args_list: assert isinstance(c.args[0], int) - def test_respects_queryset_filtering(self): + def test_respects_queryset_filtering(self) -> None: """Only items matching the queryset are processed.""" ois = self._create_org_integrations(30) @@ -343,7 +345,7 @@ def test_respects_queryset_filtering(self): dispatched_pks = [c.args[0] for c in self.mock_task.delay.call_args_list] assert ois[5].pk not in dispatched_pks - def test_lock_contention_skips_tick(self): + def test_lock_contention_skips_tick(self) -> None: """If lock is held by another tick, skip without processing.""" self._create_org_integrations(30) scheduler = self._make_scheduler() @@ -361,7 +363,7 @@ def test_lock_contention_skips_tick(self): assert has_more is False self.mock_task.delay.assert_not_called() - def test_min_batch_size(self): + def test_min_batch_size(self) -> None: """Batch size is at least 1, even when math would produce < 1.""" self._create_org_integrations(3) # 1-min tick, 100-min cycle, 3 items → ceil(3/100) = 1 @@ -377,7 +379,7 @@ def test_min_batch_size(self): scheduler.tick() assert self.mock_task.delay.call_count == 3 - def test_pks_cached_at_cycle_start(self): + def test_pks_cached_at_cycle_start(self) -> None: """PKs are snapshotted into the Redis list at the start of each cycle.""" self._create_org_integrations(30) scheduler = self._make_scheduler() @@ -386,7 +388,7 @@ def test_pks_cached_at_cycle_start(self): assert self.redis_client.llen(self.pk_list_key) == 30 - def test_cached_pks_stable_mid_cycle(self): + def test_cached_pks_stable_mid_cycle(self) -> None: """Adding items mid-cycle doesn't affect the current cycle's batch size.""" self._create_org_integrations(30) scheduler = self._make_scheduler() @@ -404,7 +406,7 @@ def test_cached_pks_stable_mid_cycle(self): # Still 10, not recalculated to 20 assert len(second_batch) == 10 - def test_cached_pks_cleared_after_cycle(self): + def test_cached_pks_cleared_after_cycle(self) -> None: """PK list is cleared when cycle completes.""" self._create_org_integrations(30) scheduler = self._make_scheduler() @@ -416,7 +418,7 @@ def test_cached_pks_cleared_after_cycle(self): assert self.redis_client.exists(self.pk_list_key) == 0 - def test_prevalidate_batch_filters_dispatches(self): + def test_prevalidate_batch_filters_dispatches(self) -> None: """When prevalidate_batch is provided, only items it keeps are dispatched.""" ois = self._create_org_integrations(30) # Only dispatch even-indexed PKs @@ -440,7 +442,7 @@ def test_prevalidate_batch_filters_dispatches(self): for pk in dispatched_pks: assert pk in even_pks - def test_validate_item_filters_dispatches(self): + def test_validate_item_filters_dispatches(self) -> None: """The per-item predicate keeps working alongside prevalidate_batch.""" ois = self._create_org_integrations(30) even_pks = {oi.pk for oi in ois[::2]} @@ -464,12 +466,12 @@ def test_validate_item_filters_dispatches(self): for pk in dispatched_pks: assert pk in even_pks - def test_prevalidate_batch_is_called_in_chunks(self): + def test_prevalidate_batch_is_called_in_chunks(self) -> None: """The rows are prevalidated a chunk at a time, so a huge queryset stays bounded.""" ois = self._create_org_integrations(5) chunk_sizes = [] - def record(rows): + def record(rows: Sequence[OrganizationIntegration]) -> list[int]: chunk_sizes.append(len(rows)) return [oi.pk for oi in rows] @@ -492,7 +494,7 @@ def record(rows): snapshot = [int(pk) for pk in self.redis_client.lrange(self.pk_list_key, 0, -1)] assert snapshot == sorted(oi.pk for oi in ois) - def test_validate_item_only_sees_prevalidated_items(self): + def test_validate_item_only_sees_prevalidated_items(self) -> None: """ The batch check runs first, so the per-item one only ever sees what the snapshot kept and its cost is paid for as few items as possible. @@ -502,7 +504,7 @@ def test_validate_item_only_sees_prevalidated_items(self): kept_by_batch = set(all_pks[:4]) seen_by_item: list[int] = [] - def record(pk): + def record(pk: int) -> bool: seen_by_item.append(pk) return True @@ -526,7 +528,7 @@ def record(pk): dispatched_pks = [c.args[0] for c in self.mock_task.delay.call_args_list] assert dispatched_pks == all_pks[:4] - def test_no_validators_dispatches_all(self): + def test_no_validators_dispatches_all(self) -> None: """When neither validator is provided (default), all items are dispatched.""" self._create_org_integrations(30) scheduler = self._make_scheduler() @@ -535,7 +537,7 @@ def test_no_validators_dispatches_all(self): assert self.mock_task.delay.call_count == 10 - def test_rejected_items_are_not_written_to_redis(self): + def test_rejected_items_are_not_written_to_redis(self) -> None: """ Only the PKs the batch check kept are snapshotted, so a cycle of an all-rejecting check holds no PKs and dispatches nothing. @@ -559,7 +561,7 @@ def test_rejected_items_are_not_written_to_redis(self): assert self.mock_task.delay.call_count == 0 assert self.redis_client.llen(self.pk_list_key) == 0 - def test_validate_item_cursor_advances_past_skipped(self): + def test_validate_item_cursor_advances_past_skipped(self) -> None: """Cursor advances based on all items in batch, not just dispatched ones.""" self._create_org_integrations(30) @@ -583,7 +585,7 @@ def test_validate_item_cursor_advances_past_skipped(self): assert cursor is not None assert int(cursor) == 10 - def test_batch_size_is_derived_from_the_prevalidated_pks(self): + def test_batch_size_is_derived_from_the_prevalidated_pks(self) -> None: """ Batch size divides what survived the batch check, so rejected items do not consume the cycle's throughput. @@ -608,7 +610,7 @@ def test_batch_size_is_derived_from_the_prevalidated_pks(self): # 15 kept over 3 ticks, not 10 of the unfiltered 30. assert self.mock_task.delay.call_count == 5 - def test_recalculates_batch_size_on_interval_change(self): + def test_recalculates_batch_size_on_interval_change(self) -> None: """When tick interval changes mid-cycle, batch size adjusts for remaining items.""" self._create_org_integrations(30) # Start with 1-min tick, 3-min cycle → 3 ticks → batch_size=10 @@ -632,7 +634,7 @@ def test_recalculates_batch_size_on_interval_change(self): scheduler.tick() assert self.mock_task.delay.call_count == 20 - def test_no_recalculation_when_interval_unchanged(self): + def test_no_recalculation_when_interval_unchanged(self) -> None: """Batch size stays the same when tick interval hasn't changed.""" self._create_org_integrations(30) scheduler = self._make_scheduler() @@ -645,7 +647,7 @@ def test_no_recalculation_when_interval_unchanged(self): scheduler.tick() assert self.mock_task.delay.call_count == 10 - def test_shuffle_randomizes_pk_order(self): + def test_shuffle_randomizes_pk_order(self) -> None: """When shuffle=True, PKs are not in ascending order.""" ois = self._create_org_integrations(30) sorted_pks = [oi.pk for oi in ois] @@ -672,7 +674,7 @@ def test_shuffle_randomizes_pk_order(self): assert set(all_dispatched) == set(sorted_pks) assert all_dispatched != sorted_pks - def test_shuffle_false_preserves_pk_order(self): + def test_shuffle_false_preserves_pk_order(self) -> None: """When shuffle=False (default), PKs are in ascending PK order.""" ois = self._create_org_integrations(30) sorted_pks = [oi.pk for oi in ois] @@ -687,7 +689,7 @@ def test_shuffle_false_preserves_pk_order(self): assert all_dispatched == sorted_pks - def test_preserve_queryset_order(self): + def test_preserve_queryset_order(self) -> None: """When enabled, preserve the queryset's explicit ordering in the PK snapshot.""" ois = self._create_org_integrations(30) queryset = OrganizationIntegration.objects.filter( @@ -711,7 +713,7 @@ def test_preserve_queryset_order(self): assert all_dispatched == [oi.pk for oi in reversed(ois)] - def test_preserve_queryset_order_with_prevalidate_batch(self): + def test_preserve_queryset_order_with_prevalidate_batch(self) -> None: """Prevalidation runs by PK range, but the snapshot still follows the queryset order.""" ois = self._create_org_integrations(5) even_pks = {oi.pk for oi in ois if oi.pk % 2 == 0} @@ -735,7 +737,7 @@ def test_preserve_queryset_order_with_prevalidate_batch(self): snapshot = [int(pk) for pk in self.redis_client.lrange(self.pk_list_key, 0, -1)] assert snapshot == sorted(even_pks, reverse=True) - def test_interval_decrease_halves_batch_size(self): + def test_interval_decrease_halves_batch_size(self) -> None: """When tick interval is halved, batch size is halved for remaining items.""" self._create_org_integrations(30) # Start with 2-min tick, 6-min cycle → 3 ticks → batch_size=10 @@ -760,15 +762,15 @@ def test_interval_decrease_halves_batch_size(self): @override_settings(TASKWORKER_SCHEDULES=TEST_SCHEDULES) class GetTickIntervalTest(TestCase): - def test_reads_timedelta_schedule(self): + def test_reads_timedelta_schedule(self) -> None: interval = _get_tick_interval("test-scheduler-beat") assert interval == timedelta(minutes=1) - def test_reads_different_interval(self): + def test_reads_different_interval(self) -> None: interval = _get_tick_interval("test-scheduler-beat-5m") assert interval == timedelta(minutes=5) - def test_errors_on_missing_key(self): + def test_errors_on_missing_key(self) -> None: with pytest.raises(ValueError, match="not found"): _get_tick_interval("nonexistent-key") @@ -780,6 +782,6 @@ def test_errors_on_missing_key(self): }, } ) - def test_errors_on_non_timedelta_schedule(self): + def test_errors_on_non_timedelta_schedule(self) -> None: with pytest.raises(TypeError, match="requires a timedelta"): _get_tick_interval("crontab-schedule") diff --git a/tests/tools/mypy_helpers/test_prevent_weaklist_additions.py b/tests/tools/mypy_helpers/test_prevent_weaklist_additions.py index eed0b9ed3743..0bf3b7a7f6c7 100644 --- a/tests/tools/mypy_helpers/test_prevent_weaklist_additions.py +++ b/tests/tools/mypy_helpers/test_prevent_weaklist_additions.py @@ -57,6 +57,48 @@ def test_removal_passes(tmp_path) -> None: assert main(("pyproject.toml",)) == 0 +def test_wildcard_narrowing_passes(tmp_path) -> None: + initial = """\ +[[tool.mypy.overrides]] +module = ["a.b.*", "c.d"] +disallow_untyped_defs = false +""" + updated = """\ +[[tool.mypy.overrides]] +module = ["a.b.e", "c.d"] +disallow_untyped_defs = false +""" + _init_repo(tmp_path) + _commit_pyproject(tmp_path, initial) + tmp_path.joinpath("pyproject.toml").write_text(updated) + + with contextlib.chdir(tmp_path): + assert main(("pyproject.toml",)) == 0 + + +def test_wildcard_narrowing_does_not_cover_unrelated_addition(tmp_path, capsys) -> None: + initial = """\ +[[tool.mypy.overrides]] +module = ["a.b.*", "c.d"] +disallow_untyped_defs = false +""" + updated = """\ +[[tool.mypy.overrides]] +module = ["a.b.e", "c.d", "x.y.z"] +disallow_untyped_defs = false +""" + _init_repo(tmp_path) + _commit_pyproject(tmp_path, initial) + tmp_path.joinpath("pyproject.toml").write_text(updated) + + with contextlib.chdir(tmp_path): + assert main(("pyproject.toml",)) == 1 + + out = capsys.readouterr().out + assert "'x.y.z'" in out + assert "'a.b.e'" not in out + + def test_addition_fails(tmp_path, capsys) -> None: initial = """\ [[tool.mypy.overrides]] diff --git a/tools/mypy_helpers/prevent_weaklist_additions.py b/tools/mypy_helpers/prevent_weaklist_additions.py index 17f0dd9724b8..66890dfee04f 100644 --- a/tools/mypy_helpers/prevent_weaklist_additions.py +++ b/tools/mypy_helpers/prevent_weaklist_additions.py @@ -6,6 +6,12 @@ from collections.abc import Sequence +def _covered_by_removed_wildcard(module: str, removed_modules: frozenset[str]) -> bool: + return any( + removed.endswith(".*") and module.startswith(removed[:-1]) for removed in removed_modules + ) + + def _modules_for_override_key(data: dict[str, object], key: str, filename: str) -> frozenset[str]: try: overrides = data["tool"]["mypy"]["overrides"] # type: ignore[index] @@ -52,7 +58,10 @@ def main(argv: Sequence[str] | None = None) -> int: for key, list_name, advice in _CHECKS: head_modules = _modules_for_override_key(head_data, key, filename) staged_modules = _modules_for_override_key(staged_data, key, filename) + removed_modules = head_modules - staged_modules for mod in sorted(staged_modules - head_modules): + if _covered_by_removed_wildcard(mod, removed_modules): + continue print( f"{filename}: '{mod}' was added to the {list_name} — " f"do not add new modules; {advice}." From 0dd5ddc7aaa5bc680c5d777743616d370ff3a308 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 09:57:13 -0700 Subject: [PATCH 002/103] fix(apidocs): resolve precise container hints OpenAPI schema generation raised UnableToProceedError for object-valued mappings and abstract Mapping/Sequence annotations introduced by the typing pass. Resolve object as a generic object and treat Mapping and Sequence like dict and list. --- src/sentry/apidocs/spectacular_ports.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sentry/apidocs/spectacular_ports.py b/src/sentry/apidocs/spectacular_ports.py index b8826c0dc90d..897fd862b8ad 100644 --- a/src/sentry/apidocs/spectacular_ports.py +++ b/src/sentry/apidocs/spectacular_ports.py @@ -129,6 +129,8 @@ def resolve_type_hint(hint) -> Any: origin, args = _get_type_hint_origin(hint) excluded_fields = get_override(hint, "exclude_fields", []) + if hint is object: + return build_basic_type(OpenApiTypes.OBJECT) if origin is None and is_basic_type(hint, allow_none=False): return build_basic_type(hint) elif origin is None and inspect.isclass(hint) and issubclass(hint, tuple): @@ -140,7 +142,7 @@ def resolve_type_hint(hint) -> Any: else: properties = {k: build_basic_type(OpenApiTypes.ANY) for k in hint._fields} # type: ignore[attr-defined] return build_object_type(properties=properties, required=properties.keys()) - elif origin is list or hint is list: + elif origin is list or hint is list or origin is collections.abc.Sequence: return build_array_type( _resolve_at(args[0], "items") if args else build_basic_type(OpenApiTypes.ANY) ) @@ -150,7 +152,7 @@ def resolve_type_hint(hint) -> Any: max_length=len(args), min_length=len(args), ) - elif origin is dict or origin is defaultdict: + elif origin is dict or origin is defaultdict or origin is collections.abc.Mapping: schema = build_basic_type(OpenApiTypes.OBJECT) if args and args[1] is not typing.Any and schema is not None: schema["additionalProperties"] = _resolve_at(args[1], "additionalProperties") From f90ec0d5a1de56a9eed26f50e3646b464096f869 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Fri, 16 Oct 2026 09:25:47 -0700 Subject: [PATCH 003/103] fix(api): Reverse previous-page ordering for descending paginators BasePaginator.build_queryset assigned the reversed ordering to queryset.query.order_b, a typo for order_by, when the queryset was already ordered by the descending key and a previous-page cursor needed ascending order. The assignment was silently dropped, so the previous page was filtered as if ascending but still sorted descending, returning the wrong rows. The ordering is now written to order_by, matching the sibling branch. --- src/sentry/api/paginator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/sentry/api/paginator.py b/src/sentry/api/paginator.py index da5d3f38befc..15033fb47a35 100644 --- a/src/sentry/api/paginator.py +++ b/src/sentry/api/paginator.py @@ -65,7 +65,6 @@ def __init__(self, func_name: str) -> None: class _PaginatorQuery(Protocol): order_by: tuple[str, ...] extra: Mapping[str, tuple[str, list[object]]] - order_b: tuple[str, ...] class _PaginatorQuerySet(Protocol): @@ -131,7 +130,7 @@ def build_queryset(self, value, is_prev): new_order_by = list(source.query.order_by) index = new_order_by.index(f"-{self.key}") new_order_by[index] = new_order_by[index][1:] - source.query.order_b = tuple(new_order_by) + source.query.order_by = tuple(new_order_by) else: if asc: queryset = queryset.order_by(self.key) From 0e0dad46271115f3821a848e65844a2031fb2d93 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 10:16:05 -0700 Subject: [PATCH 004/103] test(api): cover previous-page ordering for descending querysets Exercise the branch that reverses an explicitly descending queryset for a previous-page cursor. Without the order_by fix, the previous page returns the current row instead of the preceding page. --- tests/sentry/api/test_paginator.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/sentry/api/test_paginator.py b/tests/sentry/api/test_paginator.py index e0b9103d206e..07cf14b1e09a 100644 --- a/tests/sentry/api/test_paginator.py +++ b/tests/sentry/api/test_paginator.py @@ -331,6 +331,26 @@ def test_descending(self) -> None: assert result3.next assert not result3.prev + def test_prev_page_with_explicit_descending_queryset(self) -> None: + joined = timezone.now() + + res1 = self.create_user("foo@example.com", date_joined=joined) + res2 = self.create_user("bar@example.com", date_joined=joined + timedelta(seconds=1)) + res3 = self.create_user("baz@example.com", date_joined=joined + timedelta(seconds=2)) + + paginator = DateTimePaginator( + User.objects.order_by("-date_joined"), order_by="-date_joined" + ) + + result1 = paginator.get_result(limit=2, cursor=None) + assert list(result1) == [res3, res2] + + result2 = paginator.get_result(limit=2, cursor=result1.next) + assert list(result2) == [res1] + + result3 = paginator.get_result(limit=2, cursor=result2.prev) + assert list(result3) == [res3, res2] + def test_prev_descending_with_new(self) -> None: joined = timezone.now() From ab45ba6bb92da357a5c0fe0f1bf0e791ad391289 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 10:17:42 -0700 Subject: [PATCH 005/103] fix(apidocs): keep optional fields and object values in schemas Postponed annotations hide NotRequired from TypedDict runtime introspection, so ActorSerializerResponse.email was emitted as required and rejected the team-owner examples. Remove the postponed annotations from that module. Resolve object-valued mappings as unconstrained values instead of objects; the examples contain arrays and strings in those maps. --- src/sentry/api/serializers/models/actor.py | 2 -- src/sentry/apidocs/spectacular_ports.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/sentry/api/serializers/models/actor.py b/src/sentry/api/serializers/models/actor.py index efe5efb9aae6..1a5f6166248a 100644 --- a/src/sentry/api/serializers/models/actor.py +++ b/src/sentry/api/serializers/models/actor.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from collections.abc import Mapping from typing import Literal, NotRequired, Protocol, TypedDict, cast diff --git a/src/sentry/apidocs/spectacular_ports.py b/src/sentry/apidocs/spectacular_ports.py index 897fd862b8ad..7e70f12d20f7 100644 --- a/src/sentry/apidocs/spectacular_ports.py +++ b/src/sentry/apidocs/spectacular_ports.py @@ -130,7 +130,7 @@ def resolve_type_hint(hint) -> Any: excluded_fields = get_override(hint, "exclude_fields", []) if hint is object: - return build_basic_type(OpenApiTypes.OBJECT) + return build_basic_type(OpenApiTypes.ANY) if origin is None and is_basic_type(hint, allow_none=False): return build_basic_type(hint) elif origin is None and inspect.isclass(hint) and issubclass(hint, tuple): From d185f759d640123c7eda07f2c1bc9e175c1f5594 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 10:25:34 -0700 Subject: [PATCH 006/103] fix(releases): validate artifact manifest JSON shape The release archive manifest and artifact index are user-uploaded JSON. Validate that the decoded value is an object before treating it as one, so malformed input fails at the boundary instead of raising AttributeError deeper in the archive reader. Review split: #129432. --- src/sentry/models/releasefile.py | 21 +++++++++++++++++---- tests/sentry/models/test_releasefile.py | 10 ++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/sentry/models/releasefile.py b/src/sentry/models/releasefile.py index 54317b73ecd7..99e9948053be 100644 --- a/src/sentry/models/releasefile.py +++ b/src/sentry/models/releasefile.py @@ -40,6 +40,20 @@ logger = logging.getLogger(__name__) +def _load_json_object(data: str | bytes) -> dict[str, object]: + raw = json.loads(data) + if not isinstance(raw, dict): + raise ValueError("expected a JSON object") + return raw + + +def _load_json_object_from_file(fp: IO[bytes]) -> dict[str, object]: + raw = json.load(fp) + if not isinstance(raw, dict): + raise ValueError("expected a JSON object") + return raw + + ARTIFACT_INDEX_FILENAME = "artifact-index.json" ARTIFACT_INDEX_TYPE = "release.artifact-index" @@ -207,8 +221,7 @@ def read(self, filename: str) -> bytes: return self._zip_file.read(filename) def _read_manifest(self) -> dict[str, object]: - manifest_bytes = self.read("manifest.json") - return cast(dict[str, object], json.loads(manifest_bytes.decode("utf-8"))) + return _load_json_object(self.read("manifest.json")) def get_file_by_url(self, url: str) -> tuple[IO[bytes], dict[str, object]]: """Return file-like object and headers. @@ -288,7 +301,7 @@ def readable_data(self) -> dict[str, object] | None: else: fp = releasefile.file.getfile() with fp: - return cast(dict[str, object], json.load(fp)) + return _load_json_object_from_file(fp) @contextmanager def writable_data( @@ -323,7 +336,7 @@ def writable_data( source_file = releasefile.file if source_file.type != ARTIFACT_INDEX_TYPE: raise RuntimeError("Unexpected file type for artifact index") - raw_data: dict[str, object] = json.load(source_file.getfile()) + raw_data = _load_json_object_from_file(source_file.getfile()) index_data = _ArtifactIndexData(raw_data) yield index_data # editable reference to index diff --git a/tests/sentry/models/test_releasefile.py b/tests/sentry/models/test_releasefile.py index a8c8e11fe503..799b4558f5b6 100644 --- a/tests/sentry/models/test_releasefile.py +++ b/tests/sentry/models/test_releasefile.py @@ -11,6 +11,7 @@ from sentry.models.files.file import File from sentry.models.releasefile import ( ARTIFACT_INDEX_FILENAME, + ReleaseArchive, ReleaseFile, _ArtifactIndexGuard, delete_from_artifact_index, @@ -73,6 +74,15 @@ def test_count_artifacts(self) -> None: class ReleaseArchiveTestCase(TestCase): + def test_manifest_must_be_a_json_object(self) -> None: + buffer = BytesIO() + with ZipFile(buffer, mode="w") as zf: + zf.writestr("manifest.json", "[]") + buffer.seek(0) + + with pytest.raises(ValueError, match="expected a JSON object"): + ReleaseArchive(buffer) + def create_archive(self, fields, files, dist=None): manifest = dict( fields, files={filename: {"url": f"fake://{filename}"} for filename in files} From af7c70950e45a2ae4649c1f4136a22ea3039c947 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 10:29:17 -0700 Subject: [PATCH 007/103] ref(typing): integrate split behavior fixes Bring the reviewed behavior changes into the canonical campaign branch so the typing diff reflects the final contracts: release serializer defaults, Teams missing-id failure, blank origin preservation, Jira query preconditions, and the paginator previous-page fix. --- src/sentry/api/serializers/models/release.py | 4 +- .../api/serializers/release_details_types.py | 7 ++- .../api/serializers/rest_framework/origin.py | 4 -- src/sentry/integrations/jira/integration.py | 5 +- .../integrations/jira_server/integration.py | 2 +- src/sentry/integrations/msteams/client.py | 2 +- .../serializers/rest_framework/test_origin.py | 10 ++++ tests/sentry/api/serializers/test_release.py | 53 ++++++++++++++++++- .../integrations/msteams/test_client.py | 22 ++++++++ 9 files changed, 94 insertions(+), 15 deletions(-) diff --git a/src/sentry/api/serializers/models/release.py b/src/sentry/api/serializers/models/release.py index 5b552c7f32f0..2ac584b8a29d 100644 --- a/src/sentry/api/serializers/models/release.py +++ b/src/sentry/api/serializers/models/release.py @@ -82,7 +82,7 @@ def _expose_health_data(data: ReleaseHealthOverview | None) -> HealthData | None "totalProjectSessions24h": data["total_project_sessions_24h"], "adoption": data["adoption"], "sessionsAdoption": data["sessions_adoption"], - "stats": data.get("stats"), + "stats": {str(k): v for k, v in (data.get("stats") or {}).items()}, # XXX: legacy key, should be removed later. "hasHealthData": data["has_health_data"], } @@ -93,7 +93,7 @@ def _expose_project(project: _ProjectDict) -> SerializedProject: "id": project["id"], "slug": project["slug"], "name": project["name"], - "newGroups": project["new_groups"], + "newGroups": project["new_groups"] or 0, "platform": project["platform"], "platforms": project["platforms"], # XXX: Legacy should be removed diff --git a/src/sentry/api/serializers/release_details_types.py b/src/sentry/api/serializers/release_details_types.py index 816fffc160e3..a741fff5d923 100644 --- a/src/sentry/api/serializers/release_details_types.py +++ b/src/sentry/api/serializers/release_details_types.py @@ -1,8 +1,7 @@ -from collections.abc import Mapping from datetime import datetime from typing import Any, TypeAlias, TypedDict -from sentry.release_health.base import ReleaseHealthStats, StatsPeriod +from sentry.release_health.base import ReleaseHealthStats from sentry.users.api.serializers.user import UserSerializerResponse @@ -55,7 +54,7 @@ class HealthData(HealthDataOptional): sessionsCrashed: int sessionsErrored: int hasHealthData: bool - stats: Mapping[StatsPeriod, ReleaseHealthStats] | None + stats: dict[str, ReleaseHealthStats] class ProjectOptional(TypedDict, total=False): @@ -75,4 +74,4 @@ class BaseProject(ProjectOptional): class Project(BaseProject): - newGroups: int | None + newGroups: int diff --git a/src/sentry/api/serializers/rest_framework/origin.py b/src/sentry/api/serializers/rest_framework/origin.py index dc1052ad9787..c5d256a2fa00 100644 --- a/src/sentry/api/serializers/rest_framework/origin.py +++ b/src/sentry/api/serializers/rest_framework/origin.py @@ -1,5 +1,3 @@ -from typing import cast - from rest_framework import serializers from sentry.utils.http import parse_uri_match @@ -10,8 +8,6 @@ class OriginField(serializers.CharField): def to_internal_value(self, data: str) -> str: rv = super().to_internal_value(data) - if not rv: - return cast(str, None) self.validate_origin(rv) return rv diff --git a/src/sentry/integrations/jira/integration.py b/src/sentry/integrations/jira/integration.py index 6002d52411ce..5625c41dd643 100644 --- a/src/sentry/integrations/jira/integration.py +++ b/src/sentry/integrations/jira/integration.py @@ -810,7 +810,8 @@ def get_feedback_issue_body(self, occurrence: IssueOccurrence) -> str: return body.rstrip("\n") # remove the last new line def get_generic_issue_body(self, event: GroupEvent) -> str: - occurrence = cast(IssueOccurrence, event.occurrence) + occurrence = event.occurrence + assert occurrence is not None body = "" important = occurrence.important_evidence_display if important: @@ -931,7 +932,7 @@ def update_comment(self, issue_id, user_id, group_note): ) from e def search_issues(self, query: str | None, **kwargs) -> dict[str, Any]: - query = cast(str, query) + assert query is not None, "search_issues requires a query string" try: resp = self.get_client().search_issues(query) assert isinstance(resp, dict) diff --git a/src/sentry/integrations/jira_server/integration.py b/src/sentry/integrations/jira_server/integration.py index fa15d2343bf4..c287a34e590f 100644 --- a/src/sentry/integrations/jira_server/integration.py +++ b/src/sentry/integrations/jira_server/integration.py @@ -668,7 +668,7 @@ def update_comment( ) def search_issues(self, query: str | None, **kwargs: object) -> dict[str, Any]: - query = cast(str, query) + assert query is not None, "search_issues requires a query string" try: resp = self.get_client().search_issues(query) assert isinstance(resp, dict) diff --git a/src/sentry/integrations/msteams/client.py b/src/sentry/integrations/msteams/client.py index b6b9e832c2a7..545db8a041b9 100644 --- a/src/sentry/integrations/msteams/client.py +++ b/src/sentry/integrations/msteams/client.py @@ -52,7 +52,7 @@ def get_member_list(self, team_id: str, continuation_token: str | None = None): def get_user_conversation_id(self, user_id: str, tenant_id: str) -> str: data = {"members": [{"id": user_id}], "channelData": {"tenant": {"id": tenant_id}}} resp: dict[str, object] = self.post(self.CONVERSATION_URL, data=data) - return cast(str, resp.get("id")) + return cast(str, resp["id"]) def send_message(self, conversation_id: str, data: Mapping[str, object]) -> object: response: object = self.post(self.ACTIVITY_URL % conversation_id, data=data) diff --git a/tests/sentry/api/serializers/rest_framework/test_origin.py b/tests/sentry/api/serializers/rest_framework/test_origin.py index d41c1e54bf84..7b5867a142c3 100644 --- a/tests/sentry/api/serializers/rest_framework/test_origin.py +++ b/tests/sentry/api/serializers/rest_framework/test_origin.py @@ -14,6 +14,10 @@ class DummySerializer(serializers.Serializer[object, OriginSerializerData]): origin_field = OriginField() +class BlankOriginSerializer(serializers.Serializer[object, OriginSerializerData]): + origin_field = OriginField(allow_blank=True) + + class OriginFieldTest(TestCase): def test_valid_origin(self) -> None: urls = ["https://www.foo.com", "*", "*.domain.com", "*:80", "localhost:8080"] @@ -26,3 +30,9 @@ def test_invalid_origin(self) -> None: for url in urls: serializer = DummySerializer(data={"origin_field": url}) assert serializer.is_valid() is False + + def test_blank_origin_is_preserved(self) -> None: + serializer = BlankOriginSerializer(data={"origin_field": ""}) + + assert serializer.is_valid() + assert serializer.validated_data["origin_field"] == "" diff --git a/tests/sentry/api/serializers/test_release.py b/tests/sentry/api/serializers/test_release.py index 9270b60135b2..c7d2ed8ea7f6 100644 --- a/tests/sentry/api/serializers/test_release.py +++ b/tests/sentry/api/serializers/test_release.py @@ -10,7 +10,11 @@ from sentry import tagstore from sentry.api.endpoints.organization_releases import ReleaseSerializerWithProjects from sentry.api.serializers import serialize -from sentry.api.serializers.models.release import GroupEventReleaseSerializer, get_users_for_authors +from sentry.api.serializers.models.release import ( + GroupEventReleaseSerializer, + _expose_health_data, + get_users_for_authors, +) from sentry.integrations.models.external_actor import ExternalActor from sentry.models.commit import Commit from sentry.models.commitauthor import CommitAuthor @@ -595,6 +599,22 @@ def test_with_none_new_groups(self) -> None: assert result["version"] == "0.1" assert result["newGroups"] == 0 # Should default to 0 when None + def test_none_new_groups_per_project_defaults_to_zero(self) -> None: + project = self.create_project() + + release = Release.objects.create( + organization_id=project.organization_id, + version="0.2", + ) + release.add_project(project) + + ReleaseProject.objects.filter(release=release, project=project).update(new_groups=None) + + result = serialize(release, user=self.user, project=project) + + assert result["newGroups"] == 0 + assert result["projects"][0]["newGroups"] == 0 # Should default to 0 when None + def test_new_groups_single_release(self) -> None: """ Test new groups counts for one release with multiple projects, each having different issue counts. @@ -1263,3 +1283,34 @@ def test_external_actor_duplicate_external_name_prefers_most_recent(self) -> Non assert users[str(author.id)].get("id", "not present") == str(user2.id) assert users[str(author.id)]["email"] == "user2@company.com" assert users[str(author.id)]["name"] == "User 2" + + +class ExposeHealthDataTest(TestCase): + def test_none_and_empty_payload_returns_none(self) -> None: + assert _expose_health_data(None) is None + assert _expose_health_data({}) is None + + def test_missing_stats_defaults_to_empty_dict(self) -> None: + result = _expose_health_data( + { + "duration_p50": None, + "duration_p90": None, + "crash_free_users": None, + "crash_free_sessions": None, + "sessions_crashed": 0, + "sessions_errored": 0, + "total_users": 0, + "total_users_24h": 0, + "total_project_users_24h": 0, + "total_sessions": 0, + "total_sessions_24h": 0, + "total_project_sessions_24h": 0, + "adoption": None, + "sessions_adoption": None, + "has_health_data": False, + } + ) + + assert result is not None + assert result["stats"] == {} + assert result["sessionsCrashed"] == 0 diff --git a/tests/sentry/integrations/msteams/test_client.py b/tests/sentry/integrations/msteams/test_client.py index 95610f503eed..0ecff66bb7da 100644 --- a/tests/sentry/integrations/msteams/test_client.py +++ b/tests/sentry/integrations/msteams/test_client.py @@ -148,6 +148,28 @@ def test_skips_client_secret_expiry_gauge_when_invalid(self) -> None: get_token_data() mock_metrics.gauge.assert_not_called() + @responses.activate + def test_get_user_conversation_id(self) -> None: + responses.add( + responses.POST, + "https://smba.trafficmanager.net/amer/v3/conversations", + json={"id": "some_conversation_id"}, + ) + assert ( + self.msteams_client.get_user_conversation_id("user_id", "tenant_id") + == "some_conversation_id" + ) + + @responses.activate + def test_get_user_conversation_id_missing_id_raises(self) -> None: + responses.add( + responses.POST, + "https://smba.trafficmanager.net/amer/v3/conversations", + json={}, + ) + with pytest.raises(KeyError): + self.msteams_client.get_user_conversation_id("user_id", "tenant_id") + @responses.activate def test_simple(self) -> None: self.msteams_client.get_channel_list("foobar") From 9eb21816a05e8bd66feac1dafc96d853187f59b3 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 10:33:20 -0700 Subject: [PATCH 008/103] fix(releases): validate manifest files shape The manifest root is validated, but `files` still needs an object shape before it is counted and indexed. Reject non-object values at the archive boundary and cover both malformed shapes. Review split: #129432. --- src/sentry/models/releasefile.py | 6 ++++-- tests/sentry/models/test_releasefile.py | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/sentry/models/releasefile.py b/src/sentry/models/releasefile.py index 99e9948053be..498465c348af 100644 --- a/src/sentry/models/releasefile.py +++ b/src/sentry/models/releasefile.py @@ -193,8 +193,10 @@ def __init__(self, fileobj: IO[bytes]) -> None: self._fileobj = fileobj self._zip_file = zipfile.ZipFile(self._fileobj) self.manifest = self._read_manifest() - self.artifact_count = len(cast(dict[str, object], self.manifest.get("files", {}))) - files = cast(dict[str, dict[str, object]], self.manifest.get("files", {})) + files = self.manifest.get("files", {}) + if not isinstance(files, dict): + raise ValueError("expected manifest files to be a JSON object") + self.artifact_count = len(files) self._entries_by_url = { cast(str, entry["url"]): (path, entry) for path, entry in files.items() } diff --git a/tests/sentry/models/test_releasefile.py b/tests/sentry/models/test_releasefile.py index 799b4558f5b6..7712861c686a 100644 --- a/tests/sentry/models/test_releasefile.py +++ b/tests/sentry/models/test_releasefile.py @@ -83,6 +83,15 @@ def test_manifest_must_be_a_json_object(self) -> None: with pytest.raises(ValueError, match="expected a JSON object"): ReleaseArchive(buffer) + def test_manifest_files_must_be_a_json_object(self) -> None: + buffer = BytesIO() + with ZipFile(buffer, mode="w") as zf: + zf.writestr("manifest.json", json.dumps({"files": []})) + buffer.seek(0) + + with pytest.raises(ValueError, match="manifest files to be a JSON object"): + ReleaseArchive(buffer) + def create_archive(self, fields, files, dist=None): manifest = dict( fields, files={filename: {"url": f"fake://{filename}"} for filename in files} From 2bcd33bbbe0a0caf0e33ff7229a2a8c042ebcc15 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:01:03 -0700 Subject: [PATCH 009/103] fix(dashboards): validate threshold JSON shape Reject non-object thresholds, unknown or non-integer maximum keys, and non-string units at write time so the serializer casts are backed by a validated invariant. Review split: #129434. --- .../serializers/rest_framework/dashboard.py | 69 ++++++++++++------- .../test_organization_dashboard_details.py | 43 ++++++++++++ 2 files changed, 87 insertions(+), 25 deletions(-) diff --git a/src/sentry/api/serializers/rest_framework/dashboard.py b/src/sentry/api/serializers/rest_framework/dashboard.py index ee638e7ccea0..c31195e8aa4a 100644 --- a/src/sentry/api/serializers/rest_framework/dashboard.py +++ b/src/sentry/api/serializers/rest_framework/dashboard.py @@ -897,46 +897,65 @@ def validate(self, data): {"thresholds": "Heatmap widgets do not support thresholds."} ) - if thresholds: + if thresholds is not None: + if not isinstance(thresholds, dict): + raise serializers.ValidationError({"thresholds": "Must be an object."}) + max_values = thresholds.get("max_values") allowed_max_keys = [key.value for key in ThresholdMaxKeys] - if max_values: - for i in range(len(max_values)): - max_key = f"max{i + 1}" - - if max_key not in allowed_max_keys: + if max_values is not None: + if not isinstance(max_values, dict): + raise serializers.ValidationError( + {"thresholds": {"max_values": "Must be an object."}} + ) + if max_values: + invalid_keys = set(max_values) - set(allowed_max_keys) + if invalid_keys: raise serializers.ValidationError( - {"thresholds": f"Invalid maximum key {max_key}"} + {"thresholds": f"Invalid maximum key {sorted(invalid_keys)[0]}"} ) - if max_values.get(max_key): - if max_values.get(max_key) < 0: + for max_key, max_value in max_values.items(): + if not isinstance(max_value, int) or isinstance(max_value, bool): + raise serializers.ValidationError( + {"thresholds": {max_key: "Maximum values must be integers."}} + ) + if max_value < 0: raise serializers.ValidationError( {"thresholds": {max_key: "Maximum values can not be negative"}} ) - elif i > 0: - prev_max_key = f"max{i}" - if max_values.get(prev_max_key) and max_values.get( - prev_max_key - ) >= max_values.get(max_key): - raise serializers.ValidationError( - { - "thresholds": { - max_key: "Maximum value must be greater than minimum." - } - } - ) - if len(max_values) < len(ThresholdMaxKeys): - for key in allowed_max_keys: - if max_values.get(key) is None: + for prev_max_key, max_key in zip( + allowed_max_keys, allowed_max_keys[1:], strict=False + ): + if ( + prev_max_key in max_values + and max_key in max_values + and max_values[prev_max_key] >= max_values[max_key] + ): raise serializers.ValidationError( { "thresholds": { - key: "Must set all threshold maximums or none at all." + max_key: "Maximum value must be greater than minimum." } } ) + + if len(max_values) < len(ThresholdMaxKeys): + for key in allowed_max_keys: + if key not in max_values: + raise serializers.ValidationError( + { + "thresholds": { + key: "Must set all threshold maximums or none at all." + } + } + ) + + unit = thresholds.get("unit") + if unit is not None and not isinstance(unit, str): + raise serializers.ValidationError({"thresholds": {"unit": "Must be a string."}}) + preferred_polarity = thresholds.get("preferred_polarity") if preferred_polarity is not None and preferred_polarity not in ("+", "-", ""): raise serializers.ValidationError( diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py index debf9da0e400..80bd78530715 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py @@ -2118,6 +2118,49 @@ def test_update_widget_with_invalid_preferred_polarity(self) -> None: == "Must be '+', '-', or empty string." ) + def test_update_widget_with_invalid_thresholds(self) -> None: + cases = ( + ([], "Must be an object."), + ({"max_values": []}, "Must be an object."), + ( + {"max_values": {"max1": 100, "max3": 300}}, + "Invalid maximum key max3", + ), + ( + {"max_values": {"max1": "100", "max2": 200}}, + "Maximum values must be integers.", + ), + ( + {"max_values": {"max1": 100, "max2": 200}, "unit": 5}, + "Must be a string.", + ), + ) + for thresholds, message in cases: + with self.subTest(thresholds=thresholds): + data = { + "title": "Dashboard", + "widgets": [ + { + "id": str(self.widget_1.id), + "title": "Big Number with Invalid Thresholds", + "displayType": "big_number", + "thresholds": thresholds, + "queries": [ + { + "name": "", + "fields": ["count()"], + "columns": [], + "aggregates": ["count()"], + "conditions": "", + } + ], + }, + ], + } + response = self.do_request("put", self.url(self.dashboard.id), data=data) + assert response.status_code == 400, response.data + assert message in str(response.data) + def test_update_widget_with_invalid_threshold_time_window(self) -> None: data = { "title": "Dashboard", From a189274696bfa1ff1e3fe2cdbcbeb93bdf5e0964 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:04:27 -0700 Subject: [PATCH 010/103] fix(artifactbundle): reject non-string debug ids Manifest headers come from uploaded archives. Treat any non-string debug-id value as absent instead of passing it into symbolic, and drop the casts that assumed the header value was always a string. Review split: #129435. --- src/sentry/debug_files/artifact_bundles.py | 4 ++-- src/sentry/models/artifactbundle.py | 13 +++++++------ tests/sentry/debug_files/test_artifact_bundles.py | 9 +++++++++ 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/sentry/debug_files/artifact_bundles.py b/src/sentry/debug_files/artifact_bundles.py index 5560720f3da9..3d49fe45f267 100644 --- a/src/sentry/debug_files/artifact_bundles.py +++ b/src/sentry/debug_files/artifact_bundles.py @@ -2,7 +2,7 @@ import logging from datetime import datetime, timedelta -from typing import Any, cast +from typing import Any import orjson import sentry_sdk @@ -141,7 +141,7 @@ def is_named_after_own_debug_id(url: str, info: dict[str, Any]) -> bool: URL of every frame that has one, which the lookup resolves before it uses the URL index. """ headers = ArtifactBundleArchive.normalize_headers(info.get("headers", {})) - debug_id = ArtifactBundleArchive.normalize_debug_id(cast(str | None, headers.get("debug-id"))) + debug_id = ArtifactBundleArchive.normalize_debug_id(headers.get("debug-id")) if debug_id is None: return False file_name = url.rsplit("/", 1)[-1].lower() diff --git a/src/sentry/models/artifactbundle.py b/src/sentry/models/artifactbundle.py index 79fa174f8779..4e4aeb651a37 100644 --- a/src/sentry/models/artifactbundle.py +++ b/src/sentry/models/artifactbundle.py @@ -3,7 +3,7 @@ import zipfile from collections.abc import Callable, Iterable, Mapping from enum import Enum -from typing import IO, Any, cast +from typing import IO, Any from django.db import models from django.db.models.signals import post_delete @@ -253,8 +253,8 @@ def normalize_headers(headers: dict) -> dict[str, object]: return {k.lower(): v for k, v in headers.items()} @staticmethod - def normalize_debug_id(debug_id: str | None) -> str | None: - if debug_id is None: + def normalize_debug_id(debug_id: object) -> str | None: + if not isinstance(debug_id, str): return None try: @@ -270,8 +270,9 @@ def _build_memory_maps(self): continue # Building the map for debug_id lookup. headers = self.normalize_headers(info.get("headers", {})) - if (debug_id := cast(str | None, headers.get("debug-id"))) is not None: - debug_id = self.normalize_debug_id(debug_id) + raw_debug_id = headers.get("debug-id") + if raw_debug_id is not None: + debug_id = self.normalize_debug_id(raw_debug_id) file_type = info.get("type") if ( debug_id is not None @@ -362,7 +363,7 @@ def filter_function(_: str, info: dict) -> bool: return True headers = self.normalize_headers(info.get("headers", {})) - debug_id = self.normalize_debug_id(cast(str | None, headers.get("debug-id", None))) + debug_id = self.normalize_debug_id(headers.get("debug-id", None)) if debug_id is not None: debug_id = debug_id.lower() diff --git a/tests/sentry/debug_files/test_artifact_bundles.py b/tests/sentry/debug_files/test_artifact_bundles.py index 1b2fc59d404b..f8dcc24f9cc7 100644 --- a/tests/sentry/debug_files/test_artifact_bundles.py +++ b/tests/sentry/debug_files/test_artifact_bundles.py @@ -102,6 +102,15 @@ def get_indexed_files(project, release_name="", dist_name="", distinct=False): return list(query) +def test_normalize_debug_id_rejects_non_strings() -> None: + assert ArtifactBundleArchive.normalize_debug_id(None) is None + assert ArtifactBundleArchive.normalize_debug_id(123) is None + assert ( + ArtifactBundleArchive.normalize_debug_id("aaaaaaaa-0000-0000-0000-000000000000") + == "aaaaaaaa-0000-0000-0000-000000000000" + ) + + class ArtifactLookupTest(TestCase): def clear_cache(self): redis_client = get_redis_cluster_for_artifact_bundles() From cd058b7e30cbdb37db4a709b7b10f1a64e518471 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:06:10 -0700 Subject: [PATCH 011/103] feat(typing): add diff-aware campaign gates Gate new Any usage in graduated modules and require a `# justification:` comment for casts that read JSON, external responses, metadata, or config. The hook checks staged changes locally; `--base` and `--status` support branch-wide checks and campaign metrics. Run the hook against the stage and add tests for graduated/weaklisted modules, trust-boundary casts, and local casts. --- .pre-commit-config.yaml | 6 + .../test_check_typing_campaign.py | 106 +++++++++ tools/mypy_helpers/check_typing_campaign.py | 223 ++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 tests/tools/mypy_helpers/test_check_typing_campaign.py create mode 100644 tools/mypy_helpers/check_typing_campaign.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da277d6b0909..e2365801495c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -75,6 +75,12 @@ repos: entry: python3 -m tools.mypy_helpers.prevent_weaklist_additions files: ^pyproject\.toml$ language: python + - id: check-typing-campaign + name: check typing campaign gates + entry: python3 -m tools.mypy_helpers.check_typing_campaign + language: python + types: [python] + require_serial: true - id: prevent-push name: prevent pushing master stages: [pre-push] diff --git a/tests/tools/mypy_helpers/test_check_typing_campaign.py b/tests/tools/mypy_helpers/test_check_typing_campaign.py new file mode 100644 index 000000000000..3158bc347411 --- /dev/null +++ b/tests/tools/mypy_helpers/test_check_typing_campaign.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import contextlib +import subprocess + +from tools.mypy_helpers.check_typing_campaign import main + + +def _git(cwd, *args: str) -> None: + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) + + +def _init_repo(tmp_path) -> None: + _git(tmp_path, "init", "-q", "-b", "main") + _git(tmp_path, "config", "user.email", "test@example.com") + _git(tmp_path, "config", "user.name", "Test") + _git(tmp_path, "config", "commit.gpgsign", "false") + + +def _commit_pyproject(tmp_path, weaklist: list[str]) -> None: + modules = ", ".join(f'"{module}"' for module in weaklist) + contents = f"""\ +[[tool.mypy.overrides]] +module = [{modules}] +disallow_untyped_defs = false +""" + tmp_path.joinpath("pyproject.toml").write_text(contents) + _git(tmp_path, "add", "pyproject.toml") + _git(tmp_path, "commit", "-q", "-m", "initial") + + +def _stage_file(tmp_path, path: str, contents: str) -> None: + file_path = tmp_path / path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(contents) + _git(tmp_path, "add", path) + + +def test_any_in_graduated_module_fails(tmp_path, capsys) -> None: + _init_repo(tmp_path) + _commit_pyproject(tmp_path, []) + _stage_file( + tmp_path, + "src/sentry/foo.py", + "from typing import Any\n\nvalue: Any = 1\n", + ) + + with contextlib.chdir(tmp_path): + assert main(("src/sentry/foo.py",)) == 1 + + assert "new Any in graduated module sentry.foo" in capsys.readouterr().err + + +def test_any_in_weaklisted_module_passes(tmp_path) -> None: + _init_repo(tmp_path) + _commit_pyproject(tmp_path, ["sentry.foo"]) + _stage_file( + tmp_path, + "src/sentry/foo.py", + "from typing import Any\n\nvalue: Any = 1\n", + ) + + with contextlib.chdir(tmp_path): + assert main(("src/sentry/foo.py",)) == 0 + + +def test_trust_boundary_cast_needs_justification(tmp_path, capsys) -> None: + _init_repo(tmp_path) + _commit_pyproject(tmp_path, []) + _stage_file( + tmp_path, + "src/sentry/foo.py", + "import json\nfrom typing import cast\n\nvalue = cast(dict[str, int], json.loads('{}'))\n", + ) + + with contextlib.chdir(tmp_path): + assert main(("src/sentry/foo.py",)) == 1 + + assert "needs a '# justification:' comment" in capsys.readouterr().err + + +def test_trust_boundary_cast_with_justification_passes(tmp_path) -> None: + _init_repo(tmp_path) + _commit_pyproject(tmp_path, []) + _stage_file( + tmp_path, + "src/sentry/foo.py", + "import json\nfrom typing import cast\n\n" + "value = cast(dict[str, int], json.loads('{}')) # justification: validated shape\n", + ) + + with contextlib.chdir(tmp_path): + assert main(("src/sentry/foo.py",)) == 0 + + +def test_local_cast_passes(tmp_path) -> None: + _init_repo(tmp_path) + _commit_pyproject(tmp_path, []) + _stage_file( + tmp_path, + "src/sentry/foo.py", + "from typing import cast\n\nvalue = cast(str, 'foo')\n", + ) + + with contextlib.chdir(tmp_path): + assert main(("src/sentry/foo.py",)) == 0 diff --git a/tools/mypy_helpers/check_typing_campaign.py b/tools/mypy_helpers/check_typing_campaign.py new file mode 100644 index 000000000000..8c13f38e3721 --- /dev/null +++ b/tools/mypy_helpers/check_typing_campaign.py @@ -0,0 +1,223 @@ +"""Diff-aware typing campaign gates. + +Checks added lines in the current change for: + +- new ``Any`` usage in modules that are already off the mypy weaklist +- casts at trust boundaries without a ``# justification:`` comment + +Run without ``--base`` in a pre-commit hook to check staged changes, or with +``--base origin/master`` to check the whole branch. ``--status`` prints the +strict/weak split for changed modules instead of running the gates. +""" + +from __future__ import annotations + +import argparse +import ast +import io +import re +import subprocess +import sys +import tokenize +import tomllib +from collections.abc import Iterable, Sequence +from pathlib import Path + +from tools.mypy_helpers.prevent_weaklist_additions import _modules_for_override_key + +TRUST_BOUNDARY_CASTS = ( + re.compile(r"\bjson\.loads?\b"), + re.compile(r"\.json\(\)"), + re.compile(r"\brequests\."), + re.compile(r"\bhttpx\."), + re.compile(r"\bresponse\b"), + re.compile(r"\bresp\b"), + re.compile(r"\bheaders\.get\("), + re.compile(r"\bmetadata(?:\[|\.get\()"), + re.compile(r"\bconfig(?:\[|\.get\()"), + re.compile(r"\bself\.(?:get|post|put|request)\("), +) +JUSTIFICATION_RE = re.compile(r"#\s*justification:", re.IGNORECASE) +HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") + + +def _run_git(*args: str) -> str: + return subprocess.run(("git", *args), check=True, capture_output=True, text=True).stdout + + +def _repo_root() -> Path: + return Path(_run_git("rev-parse", "--show-toplevel").strip()) + + +def _module_for_path(path: str) -> str | None: + normalized = Path(path).as_posix() + if not normalized.startswith("src/") or not normalized.endswith(".py"): + return None + module = normalized.removesuffix(".py").removeprefix("src/").replace("/", ".") + return module.removesuffix(".__init__") + + +def _weaklist() -> frozenset[str]: + with open("pyproject.toml", "rb") as f: + data = tomllib.load(f) + return _modules_for_override_key(data, "disallow_untyped_defs", "pyproject.toml") + + +def _is_weaklisted(module: str, weaklist: frozenset[str]) -> bool: + for pattern in weaklist: + if pattern.endswith(".*"): + base = pattern[:-2] + if module == base or module.startswith(f"{base}."): + return True + elif module == pattern: + return True + return False + + +def _added_lines_by_file(diff: str) -> dict[str, set[int]]: + result: dict[str, set[int]] = {} + current: str | None = None + new_line = 0 + for line in diff.splitlines(): + if line.startswith("+++ b/"): + current = line[6:] + result.setdefault(current, set()) + elif line.startswith("@@ "): + match = HUNK_RE.match(line) + if match is None: + current = None + continue + new_line = int(match.group(1)) + elif current is not None and line.startswith("+"): + result[current].add(new_line) + new_line += 1 + elif current is not None and line.startswith(" "): + new_line += 1 + return result + + +def _diff(base: str | None, filenames: Sequence[str], staged: bool) -> dict[str, set[int]]: + paths = [path for path in filenames if path.endswith(".py")] + if base is not None: + diff = _run_git("diff", "--unified=0", f"{base}...HEAD", "--", *paths) + elif staged: + diff = _run_git("diff", "--cached", "--unified=0", "--", *paths) + else: + diff = _run_git("diff", "HEAD", "--unified=0", "--", *paths) + return _added_lines_by_file(diff) + + +def _changed_files(base: str | None) -> list[str]: + if base is not None: + diff = _run_git("diff", "--name-only", f"{base}...HEAD", "--", "src") + else: + diff = _run_git("diff", "--cached", "--name-only", "--", "src") + return [line for line in diff.splitlines() if line.endswith(".py")] + + +def _any_lines(text: str) -> set[int]: + lines = set() + for token in tokenize.generate_tokens(io.StringIO(text).readline): + if token.type == tokenize.NAME and token.string == "Any": + lines.add(token.start[0]) + return lines + + +def _cast_nodes(text: str) -> Iterable[ast.Call]: + tree = ast.parse(text) + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "cast" + and len(node.args) >= 2 + ): + yield node + + +def _check_file(path: str, added: set[int], weaklist: frozenset[str], root: Path) -> list[str]: + errors: list[str] = [] + source_path = root / path + text = source_path.read_text() + lines = text.splitlines() + module = _module_for_path(path) + + if module is not None and not _is_weaklisted(module, weaklist): + for line_number in sorted(_any_lines(text) & added): + errors.append( + f"{path}:{line_number}: new Any in graduated module {module}; " + "fix the type or add a precise contract" + ) + + for node in _cast_nodes(text): + if node.lineno not in added: + continue + value = node.args[1] + value_source = ast.get_source_segment(text, value) or "" + if not any(pattern.search(value_source) for pattern in TRUST_BOUNDARY_CASTS): + continue + previous_line = lines[node.lineno - 2] if node.lineno > 1 else "" + current_line = lines[node.lineno - 1] + if JUSTIFICATION_RE.search(current_line) or JUSTIFICATION_RE.search(previous_line): + continue + errors.append( + f"{path}:{node.lineno}: cast at a trust boundary needs a '# justification:' comment" + ) + + return errors + + +def _status(base: str | None) -> int: + weaklist = _weaklist() + files = _changed_files(base) + strong: list[str] = [] + weak: list[str] = [] + for path in files: + module = _module_for_path(path) + if module is None: + continue + (weak if _is_weaklisted(module, weaklist) else strong).append(module) + print(f"changed src files: {len(files)}") + print(f"strict-checked: {len(strong)}") + print(f"still weaklisted: {len(weak)}") + print(f"weaklist entries: {len(weaklist)}") + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("filenames", nargs="*") + parser.add_argument("--base", help="git ref to diff against") + parser.add_argument( + "--staged", action="store_true", help="check staged changes (pre-commit default)" + ) + parser.add_argument("--status", action="store_true") + args = parser.parse_args(argv) + + if args.status: + return _status(args.base) + + root = _repo_root() + try: + added = _diff(args.base, args.filenames, staged=args.staged or args.base is None) + except subprocess.CalledProcessError as exc: + print(f"unable to read diff: {exc.stderr}", file=sys.stderr) + return 2 + + weaklist = _weaklist() + errors: list[str] = [] + for path, lines in added.items(): + if not lines: + continue + source_path = root / path + if not source_path.exists(): + continue + errors.extend(_check_file(path, lines, weaklist, root)) + + for error in errors: + print(error, file=sys.stderr) + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 2dd3ccb19a9657743d6ca4a0f0584ef7978061a6 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:12:57 -0700 Subject: [PATCH 012/103] fix(typing): count only new Any in the campaign gate Compare Any tokens against the file's previous revision instead of flagging every modified line that still contains an existing Any. Limit the whole-branch mode to Python files under src/. --- tools/mypy_helpers/check_typing_campaign.py | 44 +++++++++++++++------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/tools/mypy_helpers/check_typing_campaign.py b/tools/mypy_helpers/check_typing_campaign.py index 8c13f38e3721..780c7ba1cc39 100644 --- a/tools/mypy_helpers/check_typing_campaign.py +++ b/tools/mypy_helpers/check_typing_campaign.py @@ -97,7 +97,7 @@ def _added_lines_by_file(diff: str) -> dict[str, set[int]]: def _diff(base: str | None, filenames: Sequence[str], staged: bool) -> dict[str, set[int]]: - paths = [path for path in filenames if path.endswith(".py")] + paths = [path for path in filenames if path.endswith(".py")] or ["src"] if base is not None: diff = _run_git("diff", "--unified=0", f"{base}...HEAD", "--", *paths) elif staged: @@ -115,12 +115,20 @@ def _changed_files(base: str | None) -> list[str]: return [line for line in diff.splitlines() if line.endswith(".py")] -def _any_lines(text: str) -> set[int]: - lines = set() - for token in tokenize.generate_tokens(io.StringIO(text).readline): - if token.type == tokenize.NAME and token.string == "Any": - lines.add(token.start[0]) - return lines +def _any_lines(text: str) -> list[int]: + return [ + token.start[0] + for token in tokenize.generate_tokens(io.StringIO(text).readline) + if token.type == tokenize.NAME and token.string == "Any" + ] + + +def _old_text(base: str | None, path: str, staged: bool) -> str: + ref = base if base is not None else "HEAD" + try: + return _run_git("show", f"{ref}:{path}") + except subprocess.CalledProcessError: + return "" def _cast_nodes(text: str) -> Iterable[ast.Call]: @@ -135,15 +143,26 @@ def _cast_nodes(text: str) -> Iterable[ast.Call]: yield node -def _check_file(path: str, added: set[int], weaklist: frozenset[str], root: Path) -> list[str]: +def _check_file( + path: str, + added: set[int], + weaklist: frozenset[str], + root: Path, + old_text: str, +) -> list[str]: errors: list[str] = [] source_path = root / path text = source_path.read_text() lines = text.splitlines() module = _module_for_path(path) - if module is not None and not _is_weaklisted(module, weaklist): - for line_number in sorted(_any_lines(text) & added): + new_any_lines = _any_lines(text) + if ( + module is not None + and not _is_weaklisted(module, weaklist) + and len(new_any_lines) > len(_any_lines(old_text)) + ): + for line_number in sorted(set(new_any_lines) & added): errors.append( f"{path}:{line_number}: new Any in graduated module {module}; " "fix the type or add a precise contract" @@ -207,12 +226,13 @@ def main(argv: Sequence[str] | None = None) -> int: weaklist = _weaklist() errors: list[str] = [] for path, lines in added.items(): - if not lines: + if not lines or not path.endswith(".py"): continue source_path = root / path if not source_path.exists(): continue - errors.extend(_check_file(path, lines, weaklist, root)) + old_text = _old_text(args.base, path, staged=args.staged or args.base is None) + errors.extend(_check_file(path, lines, weaklist, root, old_text)) for error in errors: print(error, file=sys.stderr) From 1e9e791f9eb97bdd020729dcd73effd0f81a5818 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:13:04 -0700 Subject: [PATCH 013/103] ref(typing): justify and validate trust-boundary casts Use a validating sourcemap-header helper instead of casting arbitrary header values, and document the remaining provider-response casts with explicit justifications so the campaign gate enforces the boundary. --- src/sentry/api/endpoints/source_map_debug.py | 21 ++++++++++--------- .../bitbucket_server/integration.py | 8 +++++-- src/sentry/integrations/gitlab/repository.py | 21 +++++++++++-------- src/sentry/integrations/jira_server/client.py | 4 ++++ src/sentry/integrations/msteams/client.py | 1 + .../api/endpoints/test_source_map_debug.py | 8 +++++++ 6 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/sentry/api/endpoints/source_map_debug.py b/src/sentry/api/endpoints/source_map_debug.py index 663963b3842f..7a649eeebb5e 100644 --- a/src/sentry/api/endpoints/source_map_debug.py +++ b/src/sentry/api/endpoints/source_map_debug.py @@ -1,6 +1,6 @@ import logging from collections.abc import Iterable, Mapping, Sequence -from typing import Any, Literal, NamedTuple, TypedDict, cast +from typing import Any, Literal, NamedTuple, TypedDict import sentry_sdk from django.db.models import Exists, OuterRef @@ -44,6 +44,13 @@ from sentry.utils import json, metrics from sentry.utils.javascript import find_sourcemap from sentry.utils.safe import get_path + + +def _sourcemap_header(headers: Mapping[str, object]) -> str | None: + value = headers.get("sourcemap", headers.get("x-sourcemap")) + return value if isinstance(value, str) else None + + from sentry.utils.urls import non_standard_url_join logger = logging.getLogger(__name__) @@ -436,9 +443,7 @@ def _find_source_file_in_basic_uploaded_files(self) -> None: headers = ArtifactBundleArchive.normalize_headers( possible_release_file.file.headers ) - sourcemap_header = cast( - "str | None", headers.get("sourcemap", headers.get("x-sourcemap")) - ) + sourcemap_header = _sourcemap_header(headers) sourcemap_bytes = ( force_bytes(sourcemap_header) if sourcemap_header is not None else None ) @@ -484,9 +489,7 @@ def _find_source_file_in_artifact_indexes(self) -> None: self.found_source_file_name ) headers = ArtifactBundleArchive.normalize_headers(headers) - sourcemap_header = cast( - "str | None", headers.get("sourcemap", headers.get("x-sourcemap")) - ) + sourcemap_header = _sourcemap_header(headers) sourcemap_bytes = ( force_bytes(sourcemap_header) if sourcemap_header is not None @@ -533,9 +536,7 @@ def _find_source_file_in_artifact_bundles(self) -> None: headers = ArtifactBundleArchive.normalize_headers(headers) self.source_file_lookup_result = "found" self.found_source_file_name = potential_source_file_name - sourcemap_header = cast( - "str | None", headers.get("sourcemap", headers.get("x-sourcemap")) - ) + sourcemap_header = _sourcemap_header(headers) sourcemap_bytes = ( force_bytes(sourcemap_header) if sourcemap_header is not None diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 455d59b77123..343d8b871b75 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -308,6 +308,8 @@ def get_repositories( ) -> list[RepositoryInfo]: if not query: resp = self.get_client().get_repos() + # justification: Bitbucket Server repository lists are JSON objects. + repos = cast(BitbucketServerReposResponse, resp) return [ { @@ -317,10 +319,12 @@ def get_repositories( "repo": repo["slug"], "name": repo["project"]["name"] + "/" + repo["name"], } - for repo in cast(BitbucketServerReposResponse, resp).get("values", []) + for repo in repos.get("values", []) ] resp = self.get_client().search_repositories(query) + # justification: Bitbucket Server repository lists are JSON objects. + repos = cast(BitbucketServerReposResponse, resp) return [ { @@ -330,7 +334,7 @@ def get_repositories( "repo": repo["slug"], "name": repo["project"]["name"] + "/" + repo["name"], } - for repo in cast(BitbucketServerReposResponse, resp).get("values", []) + for repo in repos.get("values", []) ] def has_repo_access(self, repo: RpcRepository) -> bool: diff --git a/src/sentry/integrations/gitlab/repository.py b/src/sentry/integrations/gitlab/repository.py index 1115099a92fa..f767b35f8a32 100644 --- a/src/sentry/integrations/gitlab/repository.py +++ b/src/sentry/integrations/gitlab/repository.py @@ -69,15 +69,17 @@ class GitlabRepositoryProvider(IntegrationRepositoryProvider["GitlabIntegration" def get_repository_data( self, organization: Organization, config: MutableMapping[str, object] ) -> MutableMapping[str, object]: - installation = self.get_installation( - cast(GitLabRepositorySetupConfig, config).get("installation"), organization.id - ) + # justification: repository setup config is validated by the provider. + setup_config = cast(GitLabRepositorySetupConfig, config) + installation = self.get_installation(setup_config["installation"], organization.id) client = installation.get_client() - repo_id = cast(GitLabRepositorySetupConfig, config)["identifier"] + repo_id = setup_config["identifier"] + # justification: integration metadata is written by the install pipeline. instance = cast(str, installation.model.metadata["instance"]) try: + # justification: GitLab project responses carry these keys. project = cast(GitLabProject, client.get_project(repo_id)) except Exception as e: raise installation.raise_error(e) @@ -197,21 +199,21 @@ def compare_commits( client = installation.get_client() try: if start_sha is None: + # justification: GitLab commit list responses are JSON arrays. commits = cast( Sequence[GitLabCommit], client.get_last_commits(repo.config["project_id"], end_sha), ) return self._format_commits(client, repo, commits) else: + # justification: GitLab comparison responses are JSON objects. comparison = cast( Mapping[str, object], client.compare_commits(repo.config["project_id"], start_sha, end_sha), ) - return self._format_commits( - client, - repo, - cast(Sequence[GitLabCommit], comparison["commits"]), - ) + # justification: GitLab comparison commits are JSON arrays. + commits = cast(Sequence[GitLabCommit], comparison["commits"]) + return self._format_commits(client, repo, commits) except Exception as e: raise installation.raise_error(e) @@ -241,6 +243,7 @@ def _get_patchset( """GitLab commit lists don't come with diffs so we have to make additional round trips. """ + # justification: GitLab diff responses are JSON arrays. diffs = cast(Sequence[GitLabDiff], client.get_diff(repo.config["project_id"], sha)) return self._transform_patchset(diffs) diff --git a/src/sentry/integrations/jira_server/client.py b/src/sentry/integrations/jira_server/client.py index b5e2a960bcb0..b807bd624f18 100644 --- a/src/sentry/integrations/jira_server/client.py +++ b/src/sentry/integrations/jira_server/client.py @@ -115,6 +115,7 @@ def search_issues(self, query: str) -> dict[str, object]: else: q = query.replace('"', '\\"') jql = f'text ~ "{q}"' + # justification: Jira search returns a JSON object. return cast( dict[str, object], self.get( @@ -124,6 +125,7 @@ def search_issues(self, query: str) -> dict[str, object]: ) def create_comment(self, issue_key: int | str, comment: str) -> dict[str, object]: + # justification: Jira comment creation returns a JSON object. return cast( dict[str, object], self.post(self.COMMENTS_URL % issue_key, data={"body": comment}), @@ -132,6 +134,7 @@ def create_comment(self, issue_key: int | str, comment: str) -> dict[str, object def update_comment( self, issue_key: int | str, comment_id: str, comment: str ) -> dict[str, object]: + # justification: Jira comment update returns a JSON object. return cast( dict[str, object], self.put(self.COMMENT_URL % (issue_key, comment_id), data={"body": comment}), @@ -347,6 +350,7 @@ def create_issue_webhook( # Fallback to legacy webhook endpoint if we encounter an error webhook_url = self.LEGACY_WEBHOOK_URL + # justification: Jira webhook creation returns a JSON object. return cast(dict[str, object], self.post(webhook_url, auth=auth, data=data)) def request(self, *args, **kwargs): diff --git a/src/sentry/integrations/msteams/client.py b/src/sentry/integrations/msteams/client.py index 545db8a041b9..569131741931 100644 --- a/src/sentry/integrations/msteams/client.py +++ b/src/sentry/integrations/msteams/client.py @@ -52,6 +52,7 @@ def get_member_list(self, team_id: str, continuation_token: str | None = None): def get_user_conversation_id(self, user_id: str, tenant_id: str) -> str: data = {"members": [{"id": user_id}], "channelData": {"tenant": {"id": tenant_id}}} resp: dict[str, object] = self.post(self.CONVERSATION_URL, data=data) + # justification: the conversations response contract includes an id string. return cast(str, resp["id"]) def send_message(self, conversation_id: str, data: Mapping[str, object]) -> object: diff --git a/tests/sentry/api/endpoints/test_source_map_debug.py b/tests/sentry/api/endpoints/test_source_map_debug.py index 443ccbf55555..4230b0b81d2f 100644 --- a/tests/sentry/api/endpoints/test_source_map_debug.py +++ b/tests/sentry/api/endpoints/test_source_map_debug.py @@ -15,6 +15,7 @@ MIN_JS_SDK_VERSION_FOR_DEBUG_IDS, ReleaseBundleUrls, SourceMapReleaseProcessResult, + _sourcemap_header, get_release_bundle_urls, report_unsuccessful_url_matches, ) @@ -34,6 +35,13 @@ from sentry.testutils.helpers.options import override_options from sentry.testutils.skips import requires_snuba + +def test_sourcemap_header_ignores_non_strings() -> None: + assert _sourcemap_header({"sourcemap": "foo"}) == "foo" + assert _sourcemap_header({"x-sourcemap": "bar"}) == "bar" + assert _sourcemap_header({"sourcemap": 1}) is None + + pytestmark = [requires_snuba] From 8d17ea465f543c4b73cf96debad8c31cc4b36e5b Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:29:14 -0700 Subject: [PATCH 014/103] ref(typing): graduate 16 touched weaklist modules Annotate the remaining functions in these modules and remove their weaklist entries. Full mypy passes with the stricter config, so these modules are now enforced rather than merely annotated. --- pyproject.toml | 16 ------------- src/sentry/api/serializers/models/activity.py | 10 ++++---- .../api/serializers/models/auditlogentry.py | 18 ++++++++------ src/sentry/api/serializers/models/commit.py | 21 ++++++++-------- .../api/serializers/models/eventuser.py | 3 ++- .../serializers/models/exploresavedquery.py | 13 +++++++--- .../api/serializers/models/exporteddata.py | 8 +++---- .../api/serializers/models/grouprelease.py | 24 ++++++++++++++----- .../serializers/models/projectcodeowners.py | 10 ++++---- .../api/serializers/models/userreport.py | 10 ++++---- src/sentry/integrations/types.py | 2 +- src/sentry/integrations/utils/metrics.py | 2 +- src/sentry/integrations/vsts/issues.py | 2 +- src/sentry/models/orgauthtoken.py | 4 ++-- src/sentry/notifications/validators.py | 4 +++- src/sentry/relay/config/__init__.py | 2 +- src/sentry/sdk_updates.py | 15 +++++++----- .../test_organization_dashboard_details.py | 2 +- 18 files changed, 90 insertions(+), 76 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5cfdb1c94179..00fb8947e551 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -550,15 +550,12 @@ module = [ "sentry.api.serializers", "sentry.api.serializers.base", "sentry.api.serializers.models", - "sentry.api.serializers.models.activity", "sentry.api.serializers.models.apiapplication", "sentry.api.serializers.models.apiauthorization", "sentry.api.serializers.models.apitoken", "sentry.api.serializers.models.artifactbundle", - "sentry.api.serializers.models.auditlogentry", "sentry.api.serializers.models.auth_provider", "sentry.api.serializers.models.broadcast", - "sentry.api.serializers.models.commit", "sentry.api.serializers.models.dashboard", "sentry.api.serializers.models.debug_file", "sentry.api.serializers.models.deploy", @@ -566,13 +563,9 @@ module = [ "sentry.api.serializers.models.environment", "sentry.api.serializers.models.event", "sentry.api.serializers.models.eventattachment", - "sentry.api.serializers.models.eventuser", - "sentry.api.serializers.models.exploresavedquery", - "sentry.api.serializers.models.exporteddata", "sentry.api.serializers.models.filechange", "sentry.api.serializers.models.group", "sentry.api.serializers.models.group_stream", - "sentry.api.serializers.models.grouprelease", "sentry.api.serializers.models.groupsearchview", "sentry.api.serializers.models.groupsearchviewstarred", "sentry.api.serializers.models.groupseen", @@ -581,7 +574,6 @@ module = [ "sentry.api.serializers.models.organization_access_request", "sentry.api.serializers.models.project", "sentry.api.serializers.models.project_transaction_threshold", - "sentry.api.serializers.models.projectcodeowners", "sentry.api.serializers.models.projectownership", "sentry.api.serializers.models.pullrequest", "sentry.api.serializers.models.release", @@ -590,7 +582,6 @@ module = [ "sentry.api.serializers.models.role", "sentry.api.serializers.models.seer_agentic_triage_run", "sentry.api.serializers.models.tagvalue", - "sentry.api.serializers.models.userreport", "sentry.api.serializers.models.userrollback", "sentry.api.serializers.rest_framework", "sentry.api.serializers.rest_framework.base", @@ -845,13 +836,11 @@ module = [ "sentry.integrations.slack.workspace", "sentry.integrations.tasks.*", "sentry.integrations.time_utils", - "sentry.integrations.types", "sentry.integrations.utils", "sentry.integrations.utils.atlassian_connect", "sentry.integrations.utils.commit_context", "sentry.integrations.utils.common", "sentry.integrations.utils.identities", - "sentry.integrations.utils.metrics", "sentry.integrations.utils.providers", "sentry.integrations.utils.scope", "sentry.integrations.utils.source_context", @@ -860,7 +849,6 @@ module = [ "sentry.integrations.vsts", "sentry.integrations.vsts.client", "sentry.integrations.vsts.integration", - "sentry.integrations.vsts.issues", "sentry.integrations.vsts.repository", "sentry.integrations.vsts.search", "sentry.integrations.vsts.urls", @@ -989,7 +977,6 @@ module = [ "sentry.models.organizationmember", "sentry.models.organizationmemberinvite", "sentry.models.organizationonboardingtask", - "sentry.models.orgauthtoken", "sentry.models.owner_base", "sentry.models.project", "sentry.models.projectbookmark", @@ -1054,7 +1041,6 @@ module = [ "sentry.notifications.models.*", "sentry.notifications.notification_action.grouptype", "sentry.notifications.notificationcontroller", - "sentry.notifications.validators", "sentry.objectstore.*", "sentry.onboarding_tasks.*", "sentry.options", @@ -1074,7 +1060,6 @@ module = [ "sentry.quotas.*", "sentry.receivers.*", "sentry.relay", - "sentry.relay.config", "sentry.relay.config.ai_model_costs", "sentry.relay.config.experimental", "sentry.relay.config.measurements", @@ -1115,7 +1100,6 @@ module = [ "sentry.rules.filters.*", "sentry.rules.processing.*", "sentry.scm.*", - "sentry.sdk_updates", "sentry.search", "sentry.search.events", "sentry.search.events.builder.*", diff --git a/src/sentry/api/serializers/models/activity.py b/src/sentry/api/serializers/models/activity.py index 83f7da973672..e50c42a306fb 100644 --- a/src/sentry/api/serializers/models/activity.py +++ b/src/sentry/api/serializers/models/activity.py @@ -1,6 +1,6 @@ -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from datetime import datetime -from typing import TypeAlias, TypedDict +from typing import TypeAlias, TypedDict, cast from django.contrib.auth.models import AnonymousUser @@ -284,7 +284,7 @@ def get_attrs( def serialize( self, obj: Activity, - attrs, + attrs: Mapping[str, object], user: User | RpcUser | AnonymousUser, **kwargs: object, ) -> ActivitySerializerResponse: @@ -317,8 +317,8 @@ def serialize( return { "id": str(obj.id), "commentId": str(obj.id) if obj.type == ActivityType.NOTE.value else None, - "user": attrs["user"], - "sentry_app": attrs["sentry_app"], + "user": cast(dict[str, object] | None, attrs["user"]), + "sentry_app": cast(_ActivitySentryAppEmbed | None, attrs["sentry_app"]), "type": obj.get_type_display(), "data": data, "dateCreated": obj.datetime, diff --git a/src/sentry/api/serializers/models/auditlogentry.py b/src/sentry/api/serializers/models/auditlogentry.py index b6cd29220835..24f1ed8c5b7e 100644 --- a/src/sentry/api/serializers/models/auditlogentry.py +++ b/src/sentry/api/serializers/models/auditlogentry.py @@ -2,7 +2,7 @@ from collections.abc import Mapping, Sequence from copy import copy from datetime import datetime -from typing import TypedDict, cast +from typing import Protocol, TypedDict, cast from django.contrib.auth.models import AnonymousUser from django.db.models import prefetch_related_objects @@ -16,13 +16,17 @@ from sentry.users.services.user.model import RpcUser -def fix(data): +class _Team(Protocol): + id: int + + +def fix(data: dict[str, object]) -> dict[str, object]: # There was a point in time where full Team objects # got serialized into our AuditLogEntry.data, so these # values need to be stripped and reduced down to integers - if data.get("teams"): - if hasattr(data["teams"][0], "id"): - data["teams"] = [t.id for t in data["teams"]] + teams = cast(list[object], data.get("teams")) + if teams and hasattr(teams[0], "id"): + data["teams"] = [cast(_Team, team).id for team in teams] # Only return fields in the `data` field that are actually used by the frontend # Do not add fields to this list that contain sensitive information @@ -30,7 +34,7 @@ def fix(data): return {k: v for k, v in data.items() if k in required_fields} -def override_actor_id(user): +def override_actor_id(user: User | RpcUser) -> re.Match[str] | None: # overrides the usage of actor_id only to make SCIM token # name more readable (for now) scim_prefix = "scim-internal-integration-" @@ -80,7 +84,7 @@ def get_attrs( item: { "actor": ( users[str(item.actor_id)] - if item.actor_id and not override_actor_id(item.actor) + if item.actor_id and not override_actor_id(cast(User, item.actor)) else {"name": item.get_actor_name()} ), "targetUser": users.get(str(item.target_user_id)) or item.target_user_id, diff --git a/src/sentry/api/serializers/models/commit.py b/src/sentry/api/serializers/models/commit.py index e847cae9caa7..fd2a1b06ab38 100644 --- a/src/sentry/api/serializers/models/commit.py +++ b/src/sentry/api/serializers/models/commit.py @@ -1,7 +1,7 @@ from collections import defaultdict from collections.abc import Collection, Mapping, Sequence from datetime import datetime -from typing import NotRequired, TypedDict +from typing import NotRequired, TypedDict, cast from django.contrib.auth.models import AnonymousUser @@ -13,6 +13,7 @@ from sentry.models.commit import Commit from sentry.models.commitauthor import CommitAuthor from sentry.models.pullrequest import PullRequest +from sentry.models.release import Release from sentry.models.repository import Repository from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser @@ -122,7 +123,7 @@ def get_attrs( def serialize( self, obj: Commit, - attrs, + attrs: Mapping[str, object], user: User | RpcUser | AnonymousUser, **kwargs: object, ) -> CommitSerializerResponse: @@ -130,13 +131,13 @@ def serialize( "id": obj.key, "message": obj.message, "dateCreated": obj.date_added, - "pullRequest": attrs["pull_request"], - "suspectCommitType": attrs["suspect_commit_type"], + "pullRequest": cast(PullRequestSerializerResponse | None, attrs["pull_request"]), + "suspectCommitType": cast(str, attrs["suspect_commit_type"]), } if "repository" not in self.exclude: - d["repository"] = attrs["repository"] + d["repository"] = cast(RepositorySerializerResponse, attrs["repository"]) if "author" not in self.exclude: - d["author"] = attrs["user"] + d["author"] = cast(Author | EmptyAuthor, attrs["user"]) return d @@ -176,7 +177,7 @@ def get_attrs( def serialize( self, obj: Commit, - attrs, + attrs: Mapping[str, object], user: User | RpcUser | AnonymousUser, **kwargs: object, ) -> CommitSerializerResponseWithReleases: @@ -196,12 +197,12 @@ def serialize( "dateReleased": r.date_released, "dateCreated": r.date_added, } - for r in attrs["releases"] + for r in cast(Sequence[Release], attrs["releases"]) ], } if "repository" in data: - ret["repository"] = attrs["repository"] + ret["repository"] = cast(RepositorySerializerResponse, attrs["repository"]) if "author" in data: - ret["author"] = attrs["user"] + ret["author"] = cast(Author | EmptyAuthor, attrs["user"]) return ret diff --git a/src/sentry/api/serializers/models/eventuser.py b/src/sentry/api/serializers/models/eventuser.py index 7d397cd14dd8..5508a37cc335 100644 --- a/src/sentry/api/serializers/models/eventuser.py +++ b/src/sentry/api/serializers/models/eventuser.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from datetime import datetime from typing import TypedDict @@ -29,7 +30,7 @@ class EventUserSerializer(Serializer[EventUserSerializerResponse]): def serialize( self, obj: EventUser, - attrs, + attrs: Mapping[str, object], user: User | RpcUser | AnonymousUser, **kwargs: object, ) -> EventUserSerializerResponse: diff --git a/src/sentry/api/serializers/models/exploresavedquery.py b/src/sentry/api/serializers/models/exploresavedquery.py index 495dfd55ee81..cb76b3768323 100644 --- a/src/sentry/api/serializers/models/exploresavedquery.py +++ b/src/sentry/api/serializers/models/exploresavedquery.py @@ -1,5 +1,5 @@ from collections import defaultdict -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import datetime from typing import Literal, TypedDict, cast @@ -119,8 +119,15 @@ class ExploreSavedQueryAttrs(TypedDict, total=False): @register(ExploreSavedQuery) class ExploreSavedQueryModelSerializer(Serializer[ExploreSavedQueryResponse]): - def get_attrs(self, item_list, user, **kwargs): - result: defaultdict[str, dict] = defaultdict(lambda: {"created_by": {}}) + def get_attrs( + self, + item_list: Sequence[ExploreSavedQuery], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[ExploreSavedQuery, dict[str, object]]: + result: defaultdict[ExploreSavedQuery, dict[str, object]] = defaultdict( + lambda: {"created_by": {}} + ) starred_queries = {} user_last_visited = {} diff --git a/src/sentry/api/serializers/models/exporteddata.py b/src/sentry/api/serializers/models/exporteddata.py index f9911076a7ca..668de502ea52 100644 --- a/src/sentry/api/serializers/models/exporteddata.py +++ b/src/sentry/api/serializers/models/exporteddata.py @@ -1,6 +1,6 @@ -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from datetime import datetime -from typing import TypedDict +from typing import TypedDict, cast from django.contrib.auth.models import AnonymousUser @@ -68,7 +68,7 @@ def get_attrs( def serialize( self, obj: ExportedData, - attrs, + attrs: Mapping[str, object], user: User | RpcUser | AnonymousUser, **kwargs: object, ) -> ExportedDataResponse: @@ -82,7 +82,7 @@ def serialize( return { "id": obj.id, - "user": attrs.get("user"), + "user": cast(ExportedDataUserResponse | None, attrs.get("user")), "dateCreated": obj.date_added, "dateFinished": obj.date_finished, "dateExpired": obj.date_expired, diff --git a/src/sentry/api/serializers/models/grouprelease.py b/src/sentry/api/serializers/models/grouprelease.py index d0e610ca6729..7a128d1acd3c 100644 --- a/src/sentry/api/serializers/models/grouprelease.py +++ b/src/sentry/api/serializers/models/grouprelease.py @@ -1,4 +1,4 @@ -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta from typing import NamedTuple, TypedDict, cast @@ -48,9 +48,15 @@ def get_attrs( result[item] = {"release": releases.get(item.release_id)} return result - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: GroupRelease, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> GroupReleaseSerializerResponse: return { - "release": attrs["release"], + "release": cast(ReleaseSerializerResponse | None, attrs["release"]), "environment": obj.environment, "firstSeen": obj.first_seen, "lastSeen": obj.last_seen, @@ -113,7 +119,13 @@ def get_attrs( item_stats[key] = [(k, v[item.id]) for k, v in stats.get(item.group_id, {})] return attrs - def serialize(self, obj, attrs, user, **kwargs): - result = super().serialize(obj, attrs, user) - result["stats"] = attrs["stats"] + def serialize( + self, + obj: GroupRelease, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> GroupReleaseWithStatsSerializerResponse: + result = cast(GroupReleaseWithStatsSerializerResponse, super().serialize(obj, attrs, user)) + result["stats"] = cast(dict[str, list[tuple[float, float]]], attrs["stats"]) return result diff --git a/src/sentry/api/serializers/models/projectcodeowners.py b/src/sentry/api/serializers/models/projectcodeowners.py index ff0bfa62ac2b..a96e92d39371 100644 --- a/src/sentry/api/serializers/models/projectcodeowners.py +++ b/src/sentry/api/serializers/models/projectcodeowners.py @@ -1,7 +1,7 @@ import logging -from collections.abc import Collection, Sequence +from collections.abc import Collection, Mapping, Sequence from datetime import datetime -from typing import TypedDict +from typing import TypedDict, cast from django.contrib.auth.models import AnonymousUser @@ -133,7 +133,7 @@ def get_attrs( def serialize( self, obj: ProjectCodeOwners, - attrs, + attrs: Mapping[str, object], user: User | RpcUser | AnonymousUser, **kwargs: object, ) -> ProjectCodeOwnersResponse: @@ -144,7 +144,7 @@ def serialize( "dateUpdated": obj.date_updated, "dateSynced": obj.date_synced, "codeMappingId": str(obj.repository_project_path_config_id), - "provider": attrs.get("provider", "unknown"), + "provider": cast(str, attrs.get("provider", "unknown")), } if "codeMapping" in self.expand: @@ -169,6 +169,6 @@ def serialize( if "hasTargetingContext" in self.expand: data["schema"] = _serialize_ownership_schema(obj.schema) if obj.schema else obj.schema - data["codeOwnersUrl"] = attrs.get("codeOwnersUrl", "unknown") + data["codeOwnersUrl"] = cast(str, attrs.get("codeOwnersUrl", "unknown")) return data diff --git a/src/sentry/api/serializers/models/userreport.py b/src/sentry/api/serializers/models/userreport.py index 2beb543cd95e..841813f54fe8 100644 --- a/src/sentry/api/serializers/models/userreport.py +++ b/src/sentry/api/serializers/models/userreport.py @@ -1,6 +1,6 @@ -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from datetime import datetime, timedelta -from typing import NotRequired, TypedDict +from typing import NotRequired, TypedDict, cast from django.contrib.auth.models import AnonymousUser from django.utils import timezone @@ -86,7 +86,7 @@ def get_attrs( def serialize( self, obj: UserReport, - attrs, + attrs: Mapping[str, object], user: User | RpcUser | AnonymousUser, **kwargs: object, ) -> UserReportSerializerResponse: @@ -148,12 +148,12 @@ def get_attrs( def serialize( self, obj: UserReport, - attrs, + attrs: Mapping[str, object], user: User | RpcUser | AnonymousUser, **kwargs: object, ) -> UserReportWithGroupSerializerResponse: context = super().serialize(obj, attrs, user) return { **context, - "issue": attrs["group"], + "issue": cast(dict[str, object], attrs["group"]), } diff --git a/src/sentry/integrations/types.py b/src/sentry/integrations/types.py index c8daf60afc98..eef69b224444 100644 --- a/src/sentry/integrations/types.py +++ b/src/sentry/integrations/types.py @@ -184,4 +184,4 @@ class IntegrationResponse(Generic[T]): interaction_result: EventLifecycleOutcome response: T outcome_reason: str | Exception | None = None - context_data: dict | None = None + context_data: dict[str, object] | None = None diff --git a/src/sentry/integrations/utils/metrics.py b/src/sentry/integrations/utils/metrics.py index e71fd7c0d16f..b0cc16a9b6b8 100644 --- a/src/sentry/integrations/utils/metrics.py +++ b/src/sentry/integrations/utils/metrics.py @@ -214,7 +214,7 @@ def record_event( logger.warning(key, **log_params) @staticmethod - def _report_flow_error(message) -> None: + def _report_flow_error(message: str) -> None: logger.warning("EventLifecycle flow error: %s", message) def _terminate( diff --git a/src/sentry/integrations/vsts/issues.py b/src/sentry/integrations/vsts/issues.py index 8127b617e839..03ade05ac53b 100644 --- a/src/sentry/integrations/vsts/issues.py +++ b/src/sentry/integrations/vsts/issues.py @@ -459,7 +459,7 @@ def update_comment(self, issue_id: int, user_id: int, group_note: str) -> None: # Azure does not support updating comments. pass - def search_issues(self, query: str | None, **kwargs) -> dict[str, Any]: + def search_issues(self, query: str | None, **kwargs: object) -> dict[str, Any]: client = self.get_client() integration = integration_service.get_integration( diff --git a/src/sentry/models/orgauthtoken.py b/src/sentry/models/orgauthtoken.py index 558592c24590..e1925d53133b 100644 --- a/src/sentry/models/orgauthtoken.py +++ b/src/sentry/models/orgauthtoken.py @@ -80,7 +80,7 @@ class Meta: def __str__(self) -> str: return force_str(self.token_hashed) - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, object]: return {"name": self.name, "scopes": self.get_scopes()} def get_allowed_origins(self) -> list[str]: @@ -89,7 +89,7 @@ def get_allowed_origins(self) -> list[str]: def get_scopes(self) -> list[str]: return self.scope_list - def has_scope(self, scope): + def has_scope(self, scope: str) -> bool: return scope in self.get_scopes() def is_active(self) -> bool: diff --git a/src/sentry/notifications/validators.py b/src/sentry/notifications/validators.py index 86275c215ae8..5d5f9eee0e5b 100644 --- a/src/sentry/notifications/validators.py +++ b/src/sentry/notifications/validators.py @@ -21,7 +21,9 @@ class UserNotificationSettingData(TypedDict, total=False): providers: list[str] -class UserNotificationSettingsOptionsDetailsSerializer(CamelSnakeSerializer): +class UserNotificationSettingsOptionsDetailsSerializer( + CamelSnakeSerializer[UserNotificationSettingData] +): type = serializers.CharField() scope_identifier = serializers.CharField() scope_type = serializers.CharField() diff --git a/src/sentry/relay/config/__init__.py b/src/sentry/relay/config/__init__.py index e104b0b9b20c..213e5d71a3aa 100644 --- a/src/sentry/relay/config/__init__.py +++ b/src/sentry/relay/config/__init__.py @@ -298,7 +298,7 @@ def _should_extract_abnormal_mechanism(project: Project) -> bool: ) -def _browser_name_one_of(*args): +def _browser_name_one_of(*args: str) -> dict[str, object]: """Returns a condition that matches if an event or span's browser name is one of the options in `args`. diff --git a/src/sentry/sdk_updates.py b/src/sentry/sdk_updates.py index 7a0545b67071..05f7288af337 100644 --- a/src/sentry/sdk_updates.py +++ b/src/sentry/sdk_updates.py @@ -2,7 +2,7 @@ import logging from collections.abc import Iterator, Mapping, Sequence -from typing import Protocol, TypedDict +from typing import Protocol, TypedDict, cast from django.conf import settings from django.core.cache import cache @@ -70,8 +70,8 @@ class Suggestion: def to_json(self) -> dict[str, object]: raise NotImplementedError() - def __eq__(self, other): - return self.to_json() == other.to_json() + def __eq__(self, other: object) -> bool: + return self.to_json() == cast(Suggestion, other).to_json() class EnableIntegrationSuggestion(Suggestion): @@ -381,7 +381,7 @@ def get_new_state(self, old_state: SdkSetupState) -> SdkSetupState: ... ] -def get_sdk_index(): +def get_sdk_index() -> dict[str, dict[str, object]]: """ Get the SDK index from cache, if available. @@ -396,7 +396,7 @@ def get_sdk_index(): def get_sdk_versions() -> dict[str, str]: try: rv = settings.SDK_VERSIONS - rv.update((key, info["version"]) for (key, info) in get_sdk_index().items()) + rv.update((key, cast(str, info["version"])) for (key, info) in get_sdk_index().items()) return rv except Exception: logger.exception("sentry-release-registry.sdk-versions") @@ -406,7 +406,10 @@ def get_sdk_versions() -> dict[str, str]: def get_sdk_urls() -> dict[str, str]: try: rv = dict(settings.SDK_URLS) - rv.update((key, info.get("main_docs_url", "")) for (key, info) in get_sdk_index().items()) + rv.update( + (key, cast(str, info.get("main_docs_url", ""))) + for (key, info) in get_sdk_index().items() + ) return rv except Exception: logger.exception("sentry-release-registry.sdk-urls") diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py index 80bd78530715..987b05803df3 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py @@ -2119,7 +2119,7 @@ def test_update_widget_with_invalid_preferred_polarity(self) -> None: ) def test_update_widget_with_invalid_thresholds(self) -> None: - cases = ( + cases: tuple[tuple[object, str], ...] = ( ([], "Must be an object."), ({"max_values": []}, "Must be an object."), ( From d92374674a464f65ab31dc84b1aee7e7f976f0e4 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:38:34 -0700 Subject: [PATCH 015/103] ref(typing): model getsentry broadcast extensions as optional getsentry deployments extend the admin broadcast response with plans, roles, trial status, region, platform, product, early-adopter, and organization filters. Model them as optional fields on the shared response so getsentry code stays type-compatible without a companion change. --- src/sentry/api/serializers/models/broadcast.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/sentry/api/serializers/models/broadcast.py b/src/sentry/api/serializers/models/broadcast.py index e43a5b6dcc28..aacec6a948a7 100644 --- a/src/sentry/api/serializers/models/broadcast.py +++ b/src/sentry/api/serializers/models/broadcast.py @@ -1,6 +1,6 @@ from collections.abc import Mapping, Sequence from datetime import datetime -from typing import TypedDict, cast +from typing import NotRequired, TypedDict, cast from django.contrib.auth.models import AnonymousUser from django.db.models import Count @@ -30,6 +30,14 @@ class AdminBroadcastSerializerResponse(BroadcastSerializerResponse): createdBy: str | None upstreamId: str | None syncLocked: bool + plans: NotRequired[list[str] | None] + roles: NotRequired[list[str] | None] + trialStatus: NotRequired[list[str] | None] + region: NotRequired[str | None] + platform: NotRequired[list[str] | None] + product: NotRequired[list[str] | None] + earlyAdopter: NotRequired[bool | None] + organizations: NotRequired[list[int] | None] @register(Broadcast) From 40e297bcebc26e0e27a6fce749fec5b7bce3fd48 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:40:02 -0700 Subject: [PATCH 016/103] ref(typing): remove 411 clean weaklist entries Audit the full weaklist against a no-weaklist mypy run. These 399 modules and 12 wildcard families already pass the stricter config, so remove their exemptions and keep the enforcement they already satisfy. --- pyproject.toml | 411 ------------------------------------------------- 1 file changed, 411 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 00fb8947e551..4539cb0c27ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -452,16 +452,11 @@ module = [ "fixtures.vsts", "sentry", "sentry.adoption.*", - "sentry.api", "sentry.api.authentication", "sentry.api.base", "sentry.api.client", - "sentry.api.endpoints", "sentry.api.endpoints.accept_organization_invite", "sentry.api.endpoints.accept_project_transfer", - "sentry.api.endpoints.admin_project_configs", - "sentry.api.endpoints.api_applications", - "sentry.api.endpoints.api_authorizations", "sentry.api.endpoints.artifact_bundles", "sentry.api.endpoints.artifact_lookup", "sentry.api.endpoints.assistant", @@ -472,159 +467,82 @@ module = [ "sentry.api.endpoints.broadcast_index", "sentry.api.endpoints.builtin_symbol_sources", "sentry.api.endpoints.chunk", - "sentry.api.endpoints.data_scrubbing_selector_suggestions", "sentry.api.endpoints.debug_files", "sentry.api.endpoints.event_attachment_details", - "sentry.api.endpoints.event_attachments", - "sentry.api.endpoints.event_file_committers", - "sentry.api.endpoints.index", - "sentry.api.endpoints.internal.*", - "sentry.api.endpoints.oauth_userinfo", "sentry.api.endpoints.organization_api_key_details", - "sentry.api.endpoints.organization_artifactbundle_assemble", - "sentry.api.endpoints.organization_attribute_mappings", - "sentry.api.endpoints.organization_auth_provider_details", - "sentry.api.endpoints.organization_auth_token_details", - "sentry.api.endpoints.organization_auth_tokens", "sentry.api.endpoints.organization_events", "sentry.api.endpoints.organization_events_facets", - "sentry.api.endpoints.organization_events_meta", "sentry.api.endpoints.organization_events_stats", "sentry.api.endpoints.organization_events_timeseries", "sentry.api.endpoints.organization_insights_tree", - "sentry.api.endpoints.organization_intercom_jwt", - "sentry.api.endpoints.organization_measurements_meta", - "sentry.api.endpoints.organization_metrics_meta", "sentry.api.endpoints.organization_missing_org_members", - "sentry.api.endpoints.organization_on_demand_metrics_estimation_stats", "sentry.api.endpoints.organization_onboarding_continuation_email", - "sentry.api.endpoints.organization_onboarding_tasks", "sentry.api.endpoints.organization_profiling_functions", "sentry.api.endpoints.organization_profiling_profiles", "sentry.api.endpoints.organization_releases", - "sentry.api.endpoints.organization_sampling_effective_sample_rate", "sentry.api.endpoints.organization_sampling_project_rates", - "sentry.api.endpoints.organization_sampling_project_span_counts", "sentry.api.endpoints.organization_sdk_updates", "sentry.api.endpoints.organization_sessions", - "sentry.api.endpoints.organization_stats", "sentry.api.endpoints.organization_stats_summary", "sentry.api.endpoints.organization_stats_v2", - "sentry.api.endpoints.organization_tagkey_values", - "sentry.api.endpoints.organization_trace", "sentry.api.endpoints.organization_trace_item_attributes", "sentry.api.endpoints.organization_trace_item_attributes_ranked", "sentry.api.endpoints.organization_trace_item_stats", - "sentry.api.endpoints.organization_trace_logs", - "sentry.api.endpoints.organization_trace_meta", "sentry.api.endpoints.organization_traces", "sentry.api.endpoints.organization_unsubscribe", "sentry.api.endpoints.project_artifact_bundle_files", "sentry.api.endpoints.project_create_sample", - "sentry.api.endpoints.project_overview", "sentry.api.endpoints.project_performance_general_settings", - "sentry.api.endpoints.project_profiling_profile", "sentry.api.endpoints.project_servicehook_details", "sentry.api.endpoints.project_servicehooks", - "sentry.api.endpoints.project_statistical_detectors", "sentry.api.endpoints.project_symbol_sources", "sentry.api.endpoints.project_tagkey_details", - "sentry.api.endpoints.project_tagkey_values", - "sentry.api.endpoints.project_tags", "sentry.api.endpoints.project_trace_item_details", - "sentry.api.endpoints.project_transaction_names", "sentry.api.endpoints.project_transaction_threshold", "sentry.api.endpoints.project_transaction_threshold_override", "sentry.api.endpoints.prompts_activity", "sentry.api.endpoints.relay.*", - "sentry.api.endpoints.seer_models", - "sentry.api.endpoints.setup_wizard", - "sentry.api.endpoints.timeseries", - "sentry.api.endpoints.user_organizationintegrations", - "sentry.api.endpoints.user_subscriptions", "sentry.api.exceptions", "sentry.api.fields.*", - "sentry.api.handlers", - "sentry.api.invite_helper", "sentry.api.paginator", - "sentry.api.serializers", - "sentry.api.serializers.base", - "sentry.api.serializers.models", - "sentry.api.serializers.models.apiapplication", - "sentry.api.serializers.models.apiauthorization", - "sentry.api.serializers.models.apitoken", "sentry.api.serializers.models.artifactbundle", - "sentry.api.serializers.models.auth_provider", - "sentry.api.serializers.models.broadcast", "sentry.api.serializers.models.dashboard", - "sentry.api.serializers.models.debug_file", - "sentry.api.serializers.models.deploy", "sentry.api.serializers.models.discoversavedquery", - "sentry.api.serializers.models.environment", "sentry.api.serializers.models.event", - "sentry.api.serializers.models.eventattachment", - "sentry.api.serializers.models.filechange", "sentry.api.serializers.models.group", "sentry.api.serializers.models.group_stream", - "sentry.api.serializers.models.groupsearchview", - "sentry.api.serializers.models.groupsearchviewstarred", "sentry.api.serializers.models.groupseen", - "sentry.api.serializers.models.grouptombstone", - "sentry.api.serializers.models.organization", - "sentry.api.serializers.models.organization_access_request", "sentry.api.serializers.models.project", - "sentry.api.serializers.models.project_transaction_threshold", - "sentry.api.serializers.models.projectownership", "sentry.api.serializers.models.pullrequest", "sentry.api.serializers.models.release", "sentry.api.serializers.models.release_file", - "sentry.api.serializers.models.repository", - "sentry.api.serializers.models.role", - "sentry.api.serializers.models.seer_agentic_triage_run", - "sentry.api.serializers.models.tagvalue", "sentry.api.serializers.models.userrollback", - "sentry.api.serializers.rest_framework", "sentry.api.serializers.rest_framework.base", - "sentry.api.serializers.rest_framework.commit", "sentry.api.serializers.rest_framework.dashboard", "sentry.api.serializers.rest_framework.environment", "sentry.api.serializers.rest_framework.groupsearchview", "sentry.api.serializers.rest_framework.list", "sentry.api.serializers.rest_framework.project", - "sentry.api.serializers.rest_framework.project_key", "sentry.api.serializers.rest_framework.release", "sentry.api.serializers.snuba", - "sentry.api.urls", - "sentry.api.utils", "sentry.api.validators.*", - "sentry.apidocs", - "sentry.apidocs.api_ownership_allowlist_dont_modify", - "sentry.apidocs.build", - "sentry.apidocs.extensions", "sentry.apidocs.hooks", "sentry.apidocs.parameters", "sentry.apidocs.schema", "sentry.apidocs.spectacular_ports", "sentry.assistant.*", "sentry.attachments.*", - "sentry.audit_log", - "sentry.audit_log.events", "sentry.audit_log.manager", - "sentry.audit_log.register", "sentry.backup.*", "sentry.billing.*", "sentry.cache.*", "sentry.conduit.*", "sentry.conf.*", - "sentry.constants", "sentry.consumers.*", "sentry.core.*", "sentry.culprit", - "sentry.db", "sentry.db.analyze", - "sentry.db.models", "sentry.db.models.base", - "sentry.db.models.fields", "sentry.db.models.fields.array", "sentry.db.models.fields.bounded", "sentry.db.models.fields.encryption.*", @@ -634,226 +552,122 @@ module = [ "sentry.db.models.fields.slug", "sentry.db.models.fields.text", "sentry.db.models.fields.uuid", - "sentry.db.models.query", "sentry.db.postgres.*", "sentry.db.router", "sentry.debug.*", "sentry.debug_files.*", - "sentry.discover", "sentry.discover.arithmetic", - "sentry.discover.compare_tables", - "sentry.discover.dashboard_widget_split", "sentry.discover.dataset_split", "sentry.discover.endpoints.*", "sentry.discover.models", "sentry.discover.translation.*", "sentry.event_manager", - "sentry.explore", "sentry.explore.endpoints.*", "sentry.explore.models", "sentry.explore.translation.*", - "sentry.explore.utils", "sentry.features.*", - "sentry.feedback", - "sentry.feedback.apps", "sentry.feedback.endpoints.*", "sentry.feedback.lib.*", "sentry.feedback.usecases.*", - "sentry.flags", "sentry.flags.endpoints.logs", "sentry.flags.endpoints.secrets", "sentry.flags.models", "sentry.flags.providers", - "sentry.grouping", - "sentry.grouping.context", "sentry.grouping.enhancer", - "sentry.grouping.enhancer.matchers", "sentry.grouping.enhancer.rules", - "sentry.grouping.grouptype", "sentry.grouping.strategies.*", "sentry.http", - "sentry.identity", "sentry.identity.base", "sentry.identity.github.*", "sentry.identity.gitlab.*", "sentry.identity.google.*", "sentry.identity.manager", "sentry.identity.oauth2", - "sentry.identity.pipeline", "sentry.identity.providers.*", "sentry.identity.slack.*", "sentry.identity.vsts.*", "sentry.incidents.endpoints.serializers.*", - "sentry.incidents.serializers.*", - "sentry.ingest", "sentry.ingest.consumer.*", "sentry.ingest.inbound_filters", "sentry.ingest.transaction_clusterer.*", - "sentry.insights", "sentry.insights.endpoints.*", - "sentry.insights.models", - "sentry.integrations", "sentry.integrations.api.bases.*", - "sentry.integrations.api.endpoints", "sentry.integrations.api.endpoints.data_forwarding_details", "sentry.integrations.api.endpoints.data_forwarding_index", - "sentry.integrations.api.endpoints.doc_integration_details", "sentry.integrations.api.endpoints.doc_integrations_index", - "sentry.integrations.api.endpoints.external_team_details", - "sentry.integrations.api.endpoints.external_team_index", - "sentry.integrations.api.endpoints.external_user_details", - "sentry.integrations.api.endpoints.external_user_index", - "sentry.integrations.api.endpoints.integration_features", "sentry.integrations.api.endpoints.integration_proxy", "sentry.integrations.api.endpoints.organization_code_mapping_codeowners", - "sentry.integrations.api.endpoints.organization_code_mapping_details", "sentry.integrations.api.endpoints.organization_code_mappings", - "sentry.integrations.api.endpoints.organization_coding_agents", - "sentry.integrations.api.endpoints.organization_config_integrations", - "sentry.integrations.api.endpoints.organization_integration_channel_validate", - "sentry.integrations.api.endpoints.organization_integration_channels", - "sentry.integrations.api.endpoints.organization_integration_details", - "sentry.integrations.api.endpoints.organization_integration_direct_enable", - "sentry.integrations.api.endpoints.organization_integration_issues", - "sentry.integrations.api.endpoints.organization_integration_repo_sync", - "sentry.integrations.api.endpoints.organization_integration_repos", "sentry.integrations.api.endpoints.organization_integration_request", "sentry.integrations.api.endpoints.organization_integration_serverless_functions", - "sentry.integrations.api.endpoints.organization_integrations_index", "sentry.integrations.api.endpoints.organization_repositories", "sentry.integrations.api.endpoints.organization_repository_commits", - "sentry.integrations.api.endpoints.organization_repository_details", - "sentry.integrations.api.endpoints.organization_repository_platforms", "sentry.integrations.api.endpoints.organization_repository_settings", "sentry.integrations.api.serializers.*", "sentry.integrations.aws_lambda.*", - "sentry.integrations.base", "sentry.integrations.bitbucket.*", "sentry.integrations.bitbucket_server.*", "sentry.integrations.claude_code.*", "sentry.integrations.cursor.*", "sentry.integrations.data_forwarding.*", - "sentry.integrations.discord", - "sentry.integrations.discord.analytics", "sentry.integrations.discord.client", "sentry.integrations.discord.integration", "sentry.integrations.discord.requests.*", - "sentry.integrations.discord.spec", - "sentry.integrations.discord.types", - "sentry.integrations.discord.urls", "sentry.integrations.example.*", - "sentry.integrations.github", "sentry.integrations.github.blame", "sentry.integrations.github.client", "sentry.integrations.github.installation", "sentry.integrations.github.integration", "sentry.integrations.github.issue_sync", - "sentry.integrations.github.issues", "sentry.integrations.github.search", - "sentry.integrations.github.status_check", "sentry.integrations.github.tasks.*", - "sentry.integrations.github.urls", - "sentry.integrations.github.utils", - "sentry.integrations.github.webhook_types", "sentry.integrations.github_copilot.*", - "sentry.integrations.github_enterprise", "sentry.integrations.github_enterprise.client", "sentry.integrations.github_enterprise.integration", "sentry.integrations.github_enterprise.repository", - "sentry.integrations.github_enterprise.urls", "sentry.integrations.github_enterprise.webhook", "sentry.integrations.gitlab.*", - "sentry.integrations.issue_alert_image_builder", - "sentry.integrations.jira", "sentry.integrations.jira.client", "sentry.integrations.jira.integration", - "sentry.integrations.jira.tasks", - "sentry.integrations.jira.urls", "sentry.integrations.jira.utils.*", "sentry.integrations.jira.views.*", "sentry.integrations.jira.webhooks.*", - "sentry.integrations.jira_server", "sentry.integrations.jira_server.client", "sentry.integrations.jira_server.integration", - "sentry.integrations.jira_server.search", - "sentry.integrations.jira_server.urls", "sentry.integrations.jira_server.webhooks", - "sentry.integrations.manager", "sentry.integrations.messaging.*", - "sentry.integrations.metric_alerts", "sentry.integrations.middleware.*", "sentry.integrations.mixins.*", - "sentry.integrations.models", - "sentry.integrations.models.data_forwarder", "sentry.integrations.models.data_forwarder_project", - "sentry.integrations.models.doc_integration", "sentry.integrations.models.doc_integration_avatar", "sentry.integrations.models.external_actor", - "sentry.integrations.models.external_issue", "sentry.integrations.models.integration", - "sentry.integrations.models.integration_external_project", - "sentry.integrations.models.organization_integration", "sentry.integrations.models.repository_project_path_config", "sentry.integrations.models.utils", - "sentry.integrations.msteams", "sentry.integrations.msteams.actions.*", - "sentry.integrations.msteams.analytics", "sentry.integrations.msteams.card_builder.*", "sentry.integrations.msteams.client", "sentry.integrations.msteams.integration", - "sentry.integrations.msteams.link_identity", - "sentry.integrations.msteams.linkage", - "sentry.integrations.msteams.metrics", - "sentry.integrations.msteams.parsing", - "sentry.integrations.msteams.spec", "sentry.integrations.msteams.unlink_identity", - "sentry.integrations.msteams.urls", "sentry.integrations.msteams.utils", "sentry.integrations.msteams.webhook", "sentry.integrations.on_call.*", - "sentry.integrations.opsgenie", "sentry.integrations.opsgenie.actions.*", - "sentry.integrations.opsgenie.analytics", "sentry.integrations.opsgenie.client", "sentry.integrations.opsgenie.integration", - "sentry.integrations.opsgenie.metrics", - "sentry.integrations.opsgenie.utils", "sentry.integrations.pipeline", - "sentry.integrations.slack", "sentry.integrations.slack.actions.*", - "sentry.integrations.slack.analytics", "sentry.integrations.slack.integration", "sentry.integrations.slack.message_builder.*", - "sentry.integrations.slack.metrics", "sentry.integrations.slack.sdk_client", - "sentry.integrations.slack.service", - "sentry.integrations.slack.spec", - "sentry.integrations.slack.staging.*", "sentry.integrations.slack.tasks.*", - "sentry.integrations.slack.urls", "sentry.integrations.slack.utils.*", "sentry.integrations.slack.webhooks.*", "sentry.integrations.slack.workspace", "sentry.integrations.tasks.*", "sentry.integrations.time_utils", - "sentry.integrations.utils", - "sentry.integrations.utils.atlassian_connect", - "sentry.integrations.utils.commit_context", - "sentry.integrations.utils.common", - "sentry.integrations.utils.identities", - "sentry.integrations.utils.providers", - "sentry.integrations.utils.scope", - "sentry.integrations.utils.source_context", - "sentry.integrations.utils.stacktrace_link", - "sentry.integrations.utils.webhook_viewer_context", - "sentry.integrations.vsts", "sentry.integrations.vsts.client", - "sentry.integrations.vsts.integration", - "sentry.integrations.vsts.repository", "sentry.integrations.vsts.search", - "sentry.integrations.vsts.urls", - "sentry.integrations.vsts.webhooks", - "sentry.integrations.web.*", "sentry.interfaces.*", "sentry.issues.attributes", "sentry.issues.endpoints.event_grouping_info", @@ -861,13 +675,11 @@ module = [ "sentry.issues.endpoints.event_reprocessable", "sentry.issues.endpoints.group_attachments", "sentry.issues.endpoints.group_current_release", - "sentry.issues.endpoints.group_first_last_release", "sentry.issues.endpoints.group_integrations", "sentry.issues.endpoints.group_reprocessing", "sentry.issues.endpoints.group_stats", "sentry.issues.endpoints.group_tagkey_details", "sentry.issues.endpoints.group_tagkey_values", - "sentry.issues.endpoints.group_tags", "sentry.issues.endpoints.group_user_reports", "sentry.issues.endpoints.organization_event_details", "sentry.issues.endpoints.organization_group_search_view_details_starred", @@ -875,24 +687,16 @@ module = [ "sentry.issues.endpoints.organization_issue_metrics", "sentry.issues.endpoints.organization_issue_timeseries", "sentry.issues.endpoints.organization_issues_count", - "sentry.issues.endpoints.organization_issues_resolved_in_release", - "sentry.issues.endpoints.organization_issues_with_supergroups", - "sentry.issues.endpoints.project_codeowners_details", "sentry.issues.endpoints.project_issues_resolved_in_release", "sentry.issues.endpoints.project_ownership", "sentry.issues.endpoints.project_performance_issue_settings", "sentry.issues.endpoints.project_user_issue", - "sentry.issues.endpoints.serializers", - "sentry.issues.escalating", "sentry.issues.escalating.issue_velocity", "sentry.issues.grouptype", "sentry.issues.highlights", "sentry.issues.issue_search", - "sentry.issues.search", "sentry.killswitches", - "sentry.lang", "sentry.lang.dart.*", - "sentry.lang.java", "sentry.lang.java.exceptions", "sentry.lang.java.proguard", "sentry.lang.java.utils", @@ -902,137 +706,70 @@ module = [ "sentry.loader.*", "sentry.logging.*", "sentry.management.*", - "sentry.middleware", - "sentry.middleware.access_log", "sentry.middleware.ai_agent", "sentry.middleware.auth", "sentry.middleware.customer_domain", "sentry.middleware.demo_mode_guard", "sentry.middleware.devtoolbar", - "sentry.middleware.env", - "sentry.middleware.locale", - "sentry.middleware.proxy", - "sentry.middleware.security", - "sentry.middleware.staff", - "sentry.middleware.stats", - "sentry.middleware.subdomain", - "sentry.middleware.sudo", - "sentry.middleware.superuser", - "sentry.middleware.viewer_context", - "sentry.models", "sentry.models.artifactbundle", - "sentry.models.assistant", "sentry.models.auditlogentry", "sentry.models.authidentity", - "sentry.models.authidentityreplica", "sentry.models.authprovider", - "sentry.models.authproviderreplica", "sentry.models.avatars.*", - "sentry.models.broadcast", - "sentry.models.code_review_event", "sentry.models.commit", - "sentry.models.commitauthor", - "sentry.models.commitcomparison", "sentry.models.commitfilechange", "sentry.models.counter", "sentry.models.dashboard", "sentry.models.dashboard_permissions", "sentry.models.dashboard_widget", - "sentry.models.debugfile", - "sentry.models.deletedentry", - "sentry.models.deletedorganization", - "sentry.models.deletedproject", - "sentry.models.deletedteam", "sentry.models.deploy", - "sentry.models.distribution", "sentry.models.environment", "sentry.models.eventerror", "sentry.models.featureadoption", "sentry.models.files.*", "sentry.models.group", - "sentry.models.groupbookmark", - "sentry.models.groupcommitresolution", - "sentry.models.groupemailthread", "sentry.models.groupenvironment", "sentry.models.grouphash", - "sentry.models.grouphashmetadata", "sentry.models.grouplink", "sentry.models.groupmeta", - "sentry.models.groupopenperiod", "sentry.models.groupopenperiodactivity", "sentry.models.groupowner", - "sentry.models.groupreaction", "sentry.models.grouprelease", "sentry.models.groupresolution", - "sentry.models.groupsearchview", - "sentry.models.groupsearchviewlastvisited", - "sentry.models.groupsearchviewstarred", - "sentry.models.groupseen", - "sentry.models.groupshare", - "sentry.models.groupsnooze", "sentry.models.grouptombstone", - "sentry.models.latestreporeleaseenvironment", "sentry.models.organization", - "sentry.models.organizationcontributors", - "sentry.models.organizationmember", "sentry.models.organizationmemberinvite", "sentry.models.organizationonboardingtask", - "sentry.models.owner_base", "sentry.models.project", - "sentry.models.projectbookmark", "sentry.models.projectcodeowners", "sentry.models.projectkey", "sentry.models.projectownership", - "sentry.models.projectredirect", - "sentry.models.projectsdk", "sentry.models.projectteam", - "sentry.models.promptsactivity", - "sentry.models.pullrequest", "sentry.models.recentsearch", "sentry.models.relay", "sentry.models.release", "sentry.models.release_threshold.*", - "sentry.models.releaseactivity", - "sentry.models.releasecommit", "sentry.models.releaseenvironment", "sentry.models.releasefile", - "sentry.models.releaseheadcommit", "sentry.models.releaseprojectenvironment", "sentry.models.releases.*", "sentry.models.repository", - "sentry.models.repositorysettings", - "sentry.models.rollbackorganization", - "sentry.models.rollbackuser", "sentry.models.rule", - "sentry.models.savedsearch", - "sentry.models.search_common", - "sentry.models.sentryshot", "sentry.models.sourcemapprocessingissue", - "sentry.models.statistical_detectors", "sentry.models.team", - "sentry.models.tombstone", "sentry.models.transaction_threshold", - "sentry.models.userreport", - "sentry.monitors", - "sentry.monitors.apps", "sentry.monitors.clock_dispatch", - "sentry.monitors.constants", "sentry.monitors.endpoints.*", - "sentry.monitors.grouptype", "sentry.monitors.logic.*", - "sentry.monitors.migrations.*", "sentry.monitors.models", "sentry.monitors.processing_errors.*", "sentry.monitors.rate_limit", - "sentry.monitors.schedule", "sentry.monitors.serializers", - "sentry.monitors.system_incidents", "sentry.monitors.tasks.*", "sentry.monitors.testutils", "sentry.monitors.types", "sentry.monitors.utils", "sentry.monitors.validators", - "sentry.net", "sentry.net.http", "sentry.new_migrations.*", "sentry.newsletter.*", @@ -1041,94 +778,57 @@ module = [ "sentry.notifications.models.*", "sentry.notifications.notification_action.grouptype", "sentry.notifications.notificationcontroller", - "sentry.objectstore.*", "sentry.onboarding_tasks.*", - "sentry.options", - "sentry.options.defaults", "sentry.options.manager", "sentry.options.store", "sentry.partnerships.*", - "sentry.plugins", - "sentry.plugins.base", "sentry.plugins.base.binding_manager", "sentry.plugins.interfaces.*", - "sentry.plugins.providers", "sentry.plugins.providers.base", "sentry.processing.*", "sentry.processing_errors.*", "sentry.projectoptions.*", "sentry.quotas.*", "sentry.receivers.*", - "sentry.relay", - "sentry.relay.config.ai_model_costs", "sentry.relay.config.experimental", - "sentry.relay.config.measurements", "sentry.relay.datascrubbing", - "sentry.relay.globalconfig", "sentry.relay.projectconfig_cache.*", "sentry.relay.projectconfig_debounce_cache.*", "sentry.relay.utils", - "sentry.release_health", - "sentry.release_health.base", "sentry.release_health.eap_sessions_rollout", - "sentry.release_health.metrics", "sentry.release_health.tasks", - "sentry.releases", "sentry.releases.endpoints.*", "sentry.remote_subscriptions.*", - "sentry.replays", "sentry.replays._case_studies.*", "sentry.replays.data_export", "sentry.replays.endpoints.*", - "sentry.replays.lib", "sentry.replays.lib.cache", "sentry.replays.lib.eap.*", - "sentry.replays.lib.event_linking", - "sentry.replays.lib.http", "sentry.replays.lib.kafka", "sentry.replays.lib.query", - "sentry.replays.lib.seer_api", "sentry.replays.lib.storage", "sentry.replays.models", - "sentry.replays.permissions", "sentry.replays.post_process", "sentry.replays.query", - "sentry.replays.tasks", "sentry.replays.testutils", "sentry.replays.usecases.*", - "sentry.replays.validators", "sentry.rules.filters.*", "sentry.rules.processing.*", "sentry.scm.*", - "sentry.search", - "sentry.search.events", "sentry.search.events.builder.*", - "sentry.search.events.constants", "sentry.search.events.datasets.*", "sentry.search.events.fields", - "sentry.search.events.types", - "sentry.search.snuba", "sentry.search.snuba.executors", "sentry.search.utils", - "sentry.seer", "sentry.seer.agent.*", "sentry.seer.anomaly_detection.*", - "sentry.seer.apps", "sentry.seer.assisted_query.*", "sentry.seer.autofix.*", - "sentry.seer.breakpoints", - "sentry.seer.constants", "sentry.seer.endpoints.*", "sentry.seer.entrypoints.*", - "sentry.seer.explorer.*", "sentry.seer.issue_detection", - "sentry.seer.migrations.*", "sentry.seer.models.*", - "sentry.seer.seer_setup", - "sentry.seer.sentry_data_models", - "sentry.seer.signed_seer_api", "sentry.seer.supergroups.*", - "sentry.seer.utils", "sentry.sentry_apps.alert_rule_action_creator", "sentry.sentry_apps.api.bases.*", "sentry.sentry_apps.api.endpoints.*", @@ -1137,20 +837,11 @@ module = [ "sentry.sentry_apps.logic", "sentry.sentry_apps.models.*", "sentry.sentry_apps.tasks.*", - "sentry.sentry_metrics", - "sentry.sentry_metrics.aggregation_option_registry", - "sentry.sentry_metrics.configuration", "sentry.sentry_metrics.querying.*", - "sentry.sentry_metrics.use_case_id_registry", - "sentry.sentry_metrics.use_case_utils", - "sentry.sentry_metrics.utils", - "sentry.services", - "sentry.services.eventstore", "sentry.services.eventstore.query_preprocessing", "sentry.services.filestore.*", "sentry.services.http", "sentry.services.organization.*", - "sentry.shared_integrations", "sentry.shared_integrations.response.*", "sentry.signals", "sentry.similarity.*", @@ -1164,49 +855,30 @@ module = [ "sentry.snuba.outcomes", "sentry.snuba.sessions_v2", "sentry.snuba.spans_metrics", - "sentry.spans", "sentry.spans.buffer", - "sentry.spans.buffer_logger", - "sentry.spans.consumers", "sentry.spans.consumers.process.*", - "sentry.spans.gcp_log_analyzer", "sentry.spans.log_analyzer.*", - "sentry.stacktraces", "sentry.stacktraces.functions", "sentry.stacktraces.processing", "sentry.statistical_detectors.*", "sentry.synapse.*", "sentry.tagstore.*", - "sentry.tasks", - "sentry.tasks.activity", - "sentry.tasks.ai_agent_monitoring", "sentry.tasks.assemble", "sentry.tasks.auth.*", "sentry.tasks.auto_ongoing_issues", - "sentry.tasks.auto_remove_inbox", "sentry.tasks.auto_resolve_issues", - "sentry.tasks.auto_source_code_config", "sentry.tasks.base", "sentry.tasks.clear_expired_resolutions", "sentry.tasks.collect_project_platforms", "sentry.tasks.console_platform_cleanup", - "sentry.tasks.delete_pending_groups", - "sentry.tasks.digests", - "sentry.tasks.email", "sentry.tasks.embeddings_grouping.*", "sentry.tasks.files", "sentry.tasks.groupowner", - "sentry.tasks.integrations.*", - "sentry.tasks.llm_issue_detection.*", - "sentry.tasks.merge", "sentry.tasks.options", "sentry.tasks.organization_contributors", - "sentry.tasks.ping", "sentry.tasks.process_buffer", "sentry.tasks.relay", "sentry.tasks.release_registry", - "sentry.tasks.scim.*", - "sentry.tasks.seer", "sentry.tasks.seer.agentic_triage.*", "sentry.tasks.seer.autofix", "sentry.tasks.seer.backfill_supergroups_lightweight", @@ -1215,50 +887,24 @@ module = [ "sentry.tasks.seer.lightweight_rca_cluster", "sentry.tasks.statistical_detectors", "sentry.tasks.summaries.*", - "sentry.tasks.symbolication", "sentry.tasks.user_report", - "sentry.tasks.web_vitals_issue_detection", - "sentry.tasks.weekly_escalating_forecast", - "sentry.tempest", - "sentry.tempest.models", - "sentry.tempest.permissions", "sentry.tempest.serializers", "sentry.tempest.tasks", - "sentry.tempest.utils", "sentry.templatetags.*", - "sentry.testutils", - "sentry.testutils.abstract", "sentry.testutils.asserts", - "sentry.testutils.auth", "sentry.testutils.cases", - "sentry.testutils.cell", "sentry.testutils.factories", "sentry.testutils.fixtures", - "sentry.testutils.helpers", - "sentry.testutils.helpers.analytics", "sentry.testutils.helpers.apigateway", "sentry.testutils.helpers.backups", - "sentry.testutils.helpers.data_blobs", "sentry.testutils.helpers.datetime", - "sentry.testutils.helpers.eventprocessing", "sentry.testutils.helpers.features", - "sentry.testutils.helpers.impersonation", - "sentry.testutils.helpers.integrations", - "sentry.testutils.helpers.link_header", "sentry.testutils.helpers.notifications", - "sentry.testutils.helpers.on_demand", "sentry.testutils.helpers.options", - "sentry.testutils.helpers.pagination", "sentry.testutils.helpers.redis", - "sentry.testutils.helpers.response", - "sentry.testutils.helpers.sdk", "sentry.testutils.helpers.slack", - "sentry.testutils.helpers.socket", - "sentry.testutils.helpers.uptime", "sentry.testutils.hybrid_cloud", "sentry.testutils.issue_detection.*", - "sentry.testutils.outbox", - "sentry.testutils.pytest", "sentry.testutils.pytest.fixtures", "sentry.testutils.pytest.kafka", "sentry.testutils.pytest.metrics", @@ -1268,121 +914,64 @@ module = [ "sentry.testutils.pytest.sentry", "sentry.testutils.pytest.stale_database_reads", "sentry.testutils.pytest.unittest", - "sentry.testutils.pytest.xdist", "sentry.testutils.relay", "sentry.testutils.requests", "sentry.testutils.silo", - "sentry.toolbar", "sentry.toolbar.views.*", "sentry.tsdb.*", - "sentry.uptime", - "sentry.uptime.apps", "sentry.uptime.autodetect.*", "sentry.uptime.checker_api", "sentry.uptime.config_producer", - "sentry.uptime.config_schema", "sentry.uptime.consumers.*", - "sentry.uptime.eap_utils", "sentry.uptime.endpoints.*", - "sentry.uptime.grouptype", "sentry.uptime.models", "sentry.uptime.subscriptions.*", - "sentry.uptime.types", "sentry.uptime.utils", - "sentry.utils", "sentry.utils.arroyo_producer", - "sentry.utils.auth", - "sentry.utils.avatar", - "sentry.utils.batching_kafka_consumer", - "sentry.utils.circuit_breaker", - "sentry.utils.circuit_breaker2", - "sentry.utils.codecs", "sentry.utils.concurrent", - "sentry.utils.confluent_producer", - "sentry.utils.console_platforms", "sentry.utils.cursored_scheduler", - "sentry.utils.cursors", "sentry.utils.datastructures", - "sentry.utils.dates", "sentry.utils.db", - "sentry.utils.eap", - "sentry.utils.exceptions", - "sentry.utils.groupreference", "sentry.utils.http", - "sentry.utils.in_app", - "sentry.utils.integrationdocs", - "sentry.utils.json", - "sentry.utils.jwt", "sentry.utils.managers", "sentry.utils.marketo_client", "sentry.utils.meta", "sentry.utils.mockdata.*", - "sentry.utils.options", - "sentry.utils.outcomes", - "sentry.utils.pagination_factory", - "sentry.utils.patch_set", - "sentry.utils.platform_categories", - "sentry.utils.rollout", "sentry.utils.rust", "sentry.utils.safe", "sentry.utils.samples", "sentry.utils.sdk", - "sentry.utils.serializers", "sentry.utils.session_store", - "sentry.utils.settings", "sentry.utils.snuba", - "sentry.utils.strings", - "sentry.utils.time_window", "sentry.utils.types", "sentry.utils.validators", - "sentry.utils.yaml", - "sentry.viewer_context", - "sentry.web", "sentry.web.api", "sentry.web.client_config", - "sentry.web.debug_urls", - "sentry.web.decorators", "sentry.web.forms.*", - "sentry.web.frontend", - "sentry.web.frontend.analytics", "sentry.web.frontend.auth_channel_login", - "sentry.web.frontend.auth_close", "sentry.web.frontend.auth_login", - "sentry.web.frontend.auth_logout", "sentry.web.frontend.auth_organization_login", "sentry.web.frontend.base", "sentry.web.frontend.disabled_member_view", "sentry.web.frontend.generic", "sentry.web.frontend.group_event_json", "sentry.web.frontend.group_tag_export", - "sentry.web.frontend.home", "sentry.web.frontend.idp_email_verification", - "sentry.web.frontend.js_sdk_loader", "sentry.web.frontend.mailgun_inbound_webhook", "sentry.web.frontend.oauth_device_authorization", "sentry.web.frontend.oauth_token", "sentry.web.frontend.openidtoken", "sentry.web.frontend.organization_auth_settings", - "sentry.web.frontend.organization_avatar", - "sentry.web.frontend.out", - "sentry.web.frontend.pipeline_advancer", "sentry.web.frontend.react_page", - "sentry.web.frontend.reactivate_account", "sentry.web.frontend.release_webhook", "sentry.web.frontend.setup_wizard", - "sentry.web.frontend.shared_group_details", "sentry.web.frontend.twofactor", - "sentry.web.helpers", - "sentry.web.urls", - "sentry.web_vitals.*", - "social_auth", "social_auth.backends.*", "social_auth.decorators", "social_auth.django_compat", "social_auth.exceptions", "social_auth.fields", "social_auth.models", - "social_auth.urls", "social_auth.utils", "social_auth.views", "tests", From e97b207d36319db1cd61970721af5af0ceba5fcd Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:41:09 -0700 Subject: [PATCH 017/103] feat(typing): add zero-weaklist campaign goal gate `--goal` reports the remaining weaklist count and exits nonzero until it reaches zero, making the campaign target explicit and measurable. --- .../mypy_helpers/test_check_typing_campaign.py | 18 ++++++++++++++++++ tools/mypy_helpers/check_typing_campaign.py | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/tests/tools/mypy_helpers/test_check_typing_campaign.py b/tests/tools/mypy_helpers/test_check_typing_campaign.py index 3158bc347411..8e54d36bf992 100644 --- a/tests/tools/mypy_helpers/test_check_typing_campaign.py +++ b/tests/tools/mypy_helpers/test_check_typing_campaign.py @@ -36,6 +36,24 @@ def _stage_file(tmp_path, path: str, contents: str) -> None: _git(tmp_path, "add", path) +def test_goal_fails_while_weaklist_is_nonempty(tmp_path, capsys) -> None: + _init_repo(tmp_path) + _commit_pyproject(tmp_path, ["sentry.foo"]) + + with contextlib.chdir(tmp_path): + assert main(("--goal",)) == 1 + + assert "weaklist entries remaining: 1" in capsys.readouterr().out + + +def test_goal_passes_with_empty_weaklist(tmp_path) -> None: + _init_repo(tmp_path) + _commit_pyproject(tmp_path, []) + + with contextlib.chdir(tmp_path): + assert main(("--goal",)) == 0 + + def test_any_in_graduated_module_fails(tmp_path, capsys) -> None: _init_repo(tmp_path) _commit_pyproject(tmp_path, []) diff --git a/tools/mypy_helpers/check_typing_campaign.py b/tools/mypy_helpers/check_typing_campaign.py index 780c7ba1cc39..941a4c95c080 100644 --- a/tools/mypy_helpers/check_typing_campaign.py +++ b/tools/mypy_helpers/check_typing_campaign.py @@ -211,8 +211,14 @@ def main(argv: Sequence[str] | None = None) -> int: "--staged", action="store_true", help="check staged changes (pre-commit default)" ) parser.add_argument("--status", action="store_true") + parser.add_argument("--goal", action="store_true", help="fail until the weaklist is empty") args = parser.parse_args(argv) + if args.goal: + remaining = len(_weaklist()) + print(f"weaklist entries remaining: {remaining}") + return 0 if remaining == 0 else 1 + if args.status: return _status(args.base) From 5399f8e2b1f241394983c34a88e48e5e5f28bb97 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:47:11 -0700 Subject: [PATCH 018/103] ref(typing): graduate two endpoint weaklist modules Annotate the transfer payload and artifact download return types and remove the two weaklist exemptions. Full mypy and the endpoint tests pass. --- pyproject.toml | 2 -- .../api/endpoints/accept_project_transfer.py | 20 ++++++++++--------- src/sentry/api/endpoints/artifact_lookup.py | 16 ++++++++++----- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4539cb0c27ca..948dd469837f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -456,9 +456,7 @@ module = [ "sentry.api.base", "sentry.api.client", "sentry.api.endpoints.accept_organization_invite", - "sentry.api.endpoints.accept_project_transfer", "sentry.api.endpoints.artifact_bundles", - "sentry.api.endpoints.artifact_lookup", "sentry.api.endpoints.assistant", "sentry.api.endpoints.auth_config", "sentry.api.endpoints.auth_index", diff --git a/src/sentry/api/endpoints/accept_project_transfer.py b/src/sentry/api/endpoints/accept_project_transfer.py index 15c59b1cb4ba..e924b9bb5fb0 100644 --- a/src/sentry/api/endpoints/accept_project_transfer.py +++ b/src/sentry/api/endpoints/accept_project_transfer.py @@ -1,4 +1,5 @@ import logging +from typing import cast from django.core.signing import BadSignature, SignatureExpired from django.http import Http404 @@ -43,20 +44,21 @@ class AcceptProjectTransferEndpoint(Endpoint): authentication_classes = (SessionAuthentication,) permission_classes = (SentryIsAuthenticated,) - def get_validated_data(self, data, user: User): + def get_validated_data(self, data: str, user: User) -> tuple[dict[str, object], Project]: try: - data = unsign(force_str(data), salt=SALT) + payload = cast(dict[str, object], unsign(force_str(data), salt=SALT)) except SignatureExpired: raise InvalidPayload("Project transfer link has expired.") except BadSignature: raise InvalidPayload("Could not approve transfer, please make sure link is valid.") - if data["user_id"] != user.id: + if payload["user_id"] != user.id: raise InvalidPayload("Invalid permissions") try: project = Project.objects.get( - id=data["project_id"], organization_id=data["from_organization_id"] + id=cast(str | int, payload["project_id"]), + organization_id=cast(str | int, payload["from_organization_id"]), ) except Project.DoesNotExist: raise InvalidPayload("Project no longer exists") @@ -64,10 +66,10 @@ def get_validated_data(self, data, user: User): expected_transaction_id = ProjectOption.objects.get_value( project, "sentry:project-transfer-transaction-id" ) - if data["transaction_id"] != expected_transaction_id: + if payload["transaction_id"] != expected_transaction_id: raise InvalidPayload("Invalid transaction id") - return data, project + return payload, project @sudo_required @deprecated(CELL_API_DEPRECATION_DATE, url_names=["sentry-api-0-accept-project-transfer"]) @@ -81,7 +83,7 @@ def get(self, request: Request) -> Response: raise Http404 try: - data, project = self.get_validated_data(data, request.user) + _payload, project = self.get_validated_data(data, request.user) except InvalidPayload as e: return Response({"detail": str(e)}, status=400) @@ -113,11 +115,11 @@ def post(self, request: Request) -> Response: raise Http404 try: - data, project = self.get_validated_data(data, request.user) + payload, project = self.get_validated_data(data, request.user) except InvalidPayload as e: return Response({"detail": str(e)}, status=400) - transaction_id = data["transaction_id"] + transaction_id = payload["transaction_id"] org_slug = request.data.get("organization") # DEPRECATED diff --git a/src/sentry/api/endpoints/artifact_lookup.py b/src/sentry/api/endpoints/artifact_lookup.py index f5de91eaf715..8db7323ab548 100644 --- a/src/sentry/api/endpoints/artifact_lookup.py +++ b/src/sentry/api/endpoints/artifact_lookup.py @@ -4,7 +4,13 @@ from typing import NotRequired, TypedDict from django.db.models.query import QuerySet -from django.http import Http404, HttpResponse, HttpResponseBadRequest, StreamingHttpResponse +from django.http import ( + Http404, + HttpResponse, + HttpResponseBadRequest, + HttpResponseBase, + StreamingHttpResponse, +) from rest_framework.request import Request from rest_framework.response import Response from symbolic.debuginfo import normalize_debug_id @@ -57,14 +63,14 @@ class ProjectArtifactLookupEndpoint(ProjectEndpoint): } permission_classes = (ProjectReleasePermission,) - def download_file(self, download_id, project: Project): + def download_file(self, download_id: str, project: Project) -> HttpResponseBase: split = download_id.split("/") if len(split) < 2: return HttpResponseBadRequest(content=b"Invalid download ID") - ty, ty_id, *_rest = split + ty, ty_id_str, *_rest = split try: - ty_id = int(ty_id) + ty_id = int(ty_id_str) except ValueError: return HttpResponseBadRequest(content=b"Invalid download ID") @@ -124,7 +130,7 @@ def download_file(self, download_id, project: Project): except OSError: raise Http404 - def get(self, request: Request, project: Project) -> Response: + def get(self, request: Request, project: Project) -> Response | HttpResponseBase: """ List a Project's Individual Artifacts or Bundles ```````````````````````````````````````` From b5089e6e34062e97d1568df67a483ca0f9b5194f Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:48:08 -0700 Subject: [PATCH 019/103] feat(typing): report exact and wildcard weaklist counts Track the two burn-down shapes separately so wildcard narrowing and exact-module graduation are both visible. --- tools/mypy_helpers/check_typing_campaign.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/mypy_helpers/check_typing_campaign.py b/tools/mypy_helpers/check_typing_campaign.py index 941a4c95c080..8f796dec989d 100644 --- a/tools/mypy_helpers/check_typing_campaign.py +++ b/tools/mypy_helpers/check_typing_campaign.py @@ -200,6 +200,8 @@ def _status(base: str | None) -> int: print(f"strict-checked: {len(strong)}") print(f"still weaklisted: {len(weak)}") print(f"weaklist entries: {len(weaklist)}") + print(f"exact entries: {sum(1 for entry in weaklist if not entry.endswith('.*'))}") + print(f"wildcard entries: {sum(1 for entry in weaklist if entry.endswith('.*'))}") return 0 From 8b7268af332ecc372069bf9663b38fcbb701a966 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:50:55 -0700 Subject: [PATCH 020/103] ref(typing): graduate two admin endpoint weaklist modules Type the broadcast serializer/validator selection and API key serializer and endpoint parameters, then remove both weaklist exemptions. Full mypy and the endpoint tests pass. --- pyproject.toml | 2 -- src/sentry/api/endpoints/broadcast_details.py | 10 +++++--- .../endpoints/organization_api_key_details.py | 24 ++++++++++++------- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 948dd469837f..4f3cc28e7902 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -461,13 +461,11 @@ module = [ "sentry.api.endpoints.auth_config", "sentry.api.endpoints.auth_index", "sentry.api.endpoints.auth_login", - "sentry.api.endpoints.broadcast_details", "sentry.api.endpoints.broadcast_index", "sentry.api.endpoints.builtin_symbol_sources", "sentry.api.endpoints.chunk", "sentry.api.endpoints.debug_files", "sentry.api.endpoints.event_attachment_details", - "sentry.api.endpoints.organization_api_key_details", "sentry.api.endpoints.organization_events", "sentry.api.endpoints.organization_events_facets", "sentry.api.endpoints.organization_events_stats", diff --git a/src/sentry/api/endpoints/broadcast_details.py b/src/sentry/api/endpoints/broadcast_details.py index 2ccd39d96ff5..19e55df5889f 100644 --- a/src/sentry/api/endpoints/broadcast_details.py +++ b/src/sentry/api/endpoints/broadcast_details.py @@ -56,17 +56,21 @@ def convert_args( kwargs["broadcast"] = broadcast return args, kwargs - def _get_validator(self, request: Request): + def _get_validator( + self, request: Request + ) -> type[BroadcastValidator] | type[AdminBroadcastValidator]: if request.access.has_permission("broadcasts.admin"): return AdminBroadcastValidator return BroadcastValidator - def _get_serializer(self, request: Request): + def _get_serializer( + self, request: Request + ) -> type[BroadcastSerializer] | type[AdminBroadcastSerializer]: if request.access.has_permission("broadcasts.admin"): return AdminBroadcastSerializer return BroadcastSerializer - def _serialize_response(self, request: Request, broadcast): + def _serialize_response(self, request: Request, broadcast: Broadcast) -> Response: serializer_cls = self._get_serializer(request) return self.respond(serialize(broadcast, request.user, serializer=serializer_cls())) diff --git a/src/sentry/api/endpoints/organization_api_key_details.py b/src/sentry/api/endpoints/organization_api_key_details.py index a272fd457d65..0106e6305c43 100644 --- a/src/sentry/api/endpoints/organization_api_key_details.py +++ b/src/sentry/api/endpoints/organization_api_key_details.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import cast from rest_framework import serializers, status from rest_framework.request import Request @@ -20,7 +20,7 @@ from sentry.organizations.services.organization.model import RpcOrganization -class ApiKeySerializer(serializers.ModelSerializer): +class ApiKeySerializer(serializers.ModelSerializer[ApiKey]): class Meta: model = ApiKey fields = ("label", "scope_list", "allowed_origins") @@ -45,10 +45,10 @@ class OrganizationApiKeyDetailsEndpoint(ControlSiloOrganizationEndpoint): permission_classes = (OrganizationAdminPermission,) def convert_args( - self, request: Request, api_key_id: str, *args: Any, **kwargs: Any - ) -> tuple[tuple[Any, ...], dict[str, Any]]: + self, request: Request, api_key_id: str, *args: object, **kwargs: object + ) -> tuple[tuple[object, ...], dict[str, object]]: args, kwargs = super().convert_args(request, *args, **kwargs) - organization = kwargs["organization"] + organization = cast(RpcOrganization, kwargs["organization"]) validated_api_key_id = to_valid_int_id("api_key_id", api_key_id, raise_404=True) try: kwargs["api_key"] = ApiKey.objects.get( @@ -59,7 +59,7 @@ def convert_args( return (args, kwargs) - def get(self, request: Request, api_key: ApiKey, **kwargs) -> Response: + def get(self, request: Request, api_key: ApiKey, **kwargs: object) -> Response: """ Retrieves API Key details ````````````````````````` @@ -72,7 +72,11 @@ def get(self, request: Request, api_key: ApiKey, **kwargs) -> Response: return Response(serialize(api_key, request.user)) def put( - self, request: Request, api_key: ApiKey, organization: RpcOrganization, **kwargs + self, + request: Request, + api_key: ApiKey, + organization: RpcOrganization, + **kwargs: object, ) -> Response: """ Update an API Key @@ -104,7 +108,11 @@ def put( return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete( - self, request: Request, api_key: ApiKey, organization: RpcOrganization, **kwargs + self, + request: Request, + api_key: ApiKey, + organization: RpcOrganization, + **kwargs: object, ) -> Response: """ Deletes an API Key From d2bb3b211cfc7ac4aa5101f0c932c9418a6eac29 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:55:15 -0700 Subject: [PATCH 021/103] ref(typing): graduate opsgenie client weaklist module Type the alert payload, notification responses, and metric-alert status lookup, then remove the weaklist exemption. Full mypy and the opsgenie tests pass. --- pyproject.toml | 1 - src/sentry/integrations/opsgenie/client.py | 27 ++++++++++++++++------ src/sentry/integrations/opsgenie/utils.py | 5 +++- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4f3cc28e7902..b4f2606fbef8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -649,7 +649,6 @@ module = [ "sentry.integrations.msteams.webhook", "sentry.integrations.on_call.*", "sentry.integrations.opsgenie.actions.*", - "sentry.integrations.opsgenie.client", "sentry.integrations.opsgenie.integration", "sentry.integrations.pipeline", "sentry.integrations.slack.actions.*", diff --git a/src/sentry/integrations/opsgenie/client.py b/src/sentry/integrations/opsgenie/client.py index ef6ca8febef0..086a6392694e 100644 --- a/src/sentry/integrations/opsgenie/client.py +++ b/src/sentry/integrations/opsgenie/client.py @@ -1,8 +1,10 @@ from __future__ import annotations import logging -from collections.abc import Sequence -from typing import Literal +from collections.abc import Mapping, Sequence +from typing import Literal, NotRequired, TypedDict + +from requests import Response from sentry.integrations.client import ApiClient from sentry.integrations.models.integration import Integration @@ -23,6 +25,17 @@ OpsgeniePriority = Literal["P1", "P2", "P3", "P4", "P5"] + +class _IssueAlertPayload(TypedDict): + message: str | None + source: str + priority: OpsgeniePriority | None + details: dict[str, object] + tags: list[str] + alias: NotRequired[str] + entity: NotRequired[str] + + logger = logging.getLogger("sentry.integrations.opsgenie") # Opsgenie/JSM caps the total size of `details`, so keep the flags entry bounded. @@ -53,7 +66,7 @@ def __init__(self, integration: RpcIntegration | Integration, integration_key: s super().__init__(integration_id=self.integration.id) @property - def metadata(self): + def metadata(self) -> Mapping[str, object]: return self.integration.metadata def _get_auth_headers(self) -> dict[str, str]: @@ -104,11 +117,11 @@ def build_issue_alert_payload( group: Group | None, priority: OpsgeniePriority | None = "P3", notification_uuid: str | None = None, - ): + ) -> _IssueAlertPayload: feature_flags = format_feature_flags_detail(get_event_feature_flags(event)) feature_flags_context = {"Feature Flags": feature_flags} if feature_flags else {} - payload: dict[str, object] = { + payload: _IssueAlertPayload = { "message": event.message or event.title, "source": "Sentry", "priority": priority, @@ -148,7 +161,7 @@ def build_issue_alert_payload( } return payload - def send_notification(self, data): + def send_notification(self, data: Mapping[str, object]) -> Response: headers = self._get_auth_headers() with record_event(OnCallInteractionType.CREATE).capture() as lifecycle: try: @@ -158,7 +171,7 @@ def send_notification(self, data): raise # TODO(iamrajjoshi): We need to delete this method during notification platform - def send_metric_alert_notification(self, data): + def send_metric_alert_notification(self, data: Mapping[str, object]) -> Response | None: headers = self._get_auth_headers() # If closing an alert (when Sentry alert was resolved) diff --git a/src/sentry/integrations/opsgenie/utils.py b/src/sentry/integrations/opsgenie/utils.py index ac5e674f0dcf..2d5ab90aa1e1 100644 --- a/src/sentry/integrations/opsgenie/utils.py +++ b/src/sentry/integrations/opsgenie/utils.py @@ -3,6 +3,8 @@ import logging from typing import Any, cast +from requests import Response + from sentry.constants import ObjectStatus from sentry.incidents.models.incident import IncidentStatus from sentry.incidents.typings.metric_detector import ( @@ -131,7 +133,8 @@ def send_incident_alert_notification( logger.info( "rule.success.opsgenie_incident_alert", extra={ - "status_code": resp.status_code, + # justification: a sent alert returns the HTTP response. + "status_code": cast(Response, resp).status_code, "organization_id": organization.id, "data": attachment, "status": metric_issue_context.new_status.value, From 9cbf4aa3889aacf1ff956a612c7cfb2a469b869d Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:51:55 -0700 Subject: [PATCH 022/103] refactor(typing): graduate lane 2 weaklist modules --- .../organization_events_timeseries.py | 3 +- .../api/endpoints/project_create_sample.py | 9 ++++- src/sentry/apidocs/parameters.py | 2 +- src/sentry/db/models/base.py | 4 +- src/sentry/grouping/enhancer/__init__.py | 2 +- .../endpoints/organization_repositories.py | 2 +- .../github_enterprise/repository.py | 6 ++- src/sentry/integrations/models/utils.py | 2 +- src/sentry/integrations/slack/workspace.py | 2 +- src/sentry/issues/endpoints/event_owners.py | 3 +- .../issues/endpoints/group_integrations.py | 4 +- .../issues/endpoints/group_tagkey_details.py | 3 +- ...ation_group_search_view_details_starred.py | 4 +- .../issues/escalating/issue_velocity.py | 2 +- src/sentry/middleware/ai_agent.py | 3 +- src/sentry/models/dashboard_permissions.py | 2 +- src/sentry/models/groupopenperiodactivity.py | 2 +- src/sentry/relay/config/experimental.py | 2 +- src/sentry/search/utils.py | 2 +- src/sentry/tasks/clear_expired_resolutions.py | 2 +- src/sentry/tasks/seer/autofix.py | 3 +- src/sentry/testutils/helpers/redis.py | 4 +- src/sentry/uptime/checker_api.py | 37 ++++++++++++++----- src/sentry/web/frontend/auth_channel_login.py | 9 ++++- src/sentry/web/frontend/group_tag_export.py | 11 +++++- src/sentry/web/frontend/twofactor.py | 2 +- 26 files changed, 89 insertions(+), 38 deletions(-) diff --git a/src/sentry/api/endpoints/organization_events_timeseries.py b/src/sentry/api/endpoints/organization_events_timeseries.py index 0a5e4115ec19..ececa63461b1 100644 --- a/src/sentry/api/endpoints/organization_events_timeseries.py +++ b/src/sentry/api/endpoints/organization_events_timeseries.py @@ -1,6 +1,7 @@ import logging from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone +from types import ModuleType from typing import Any import sentry_sdk @@ -657,7 +658,7 @@ def serialize_stats_data( axes: list[str], snuba_params: SnubaParams, rollup: int, - dataset, + dataset: ModuleType, organization: Organization, ingestion_delay_status: IngestionDelayStatus | None = None, ) -> StatsResponse: diff --git a/src/sentry/api/endpoints/project_create_sample.py b/src/sentry/api/endpoints/project_create_sample.py index 140151aa3be5..13d8ed933ec5 100644 --- a/src/sentry/api/endpoints/project_create_sample.py +++ b/src/sentry/api/endpoints/project_create_sample.py @@ -9,6 +9,7 @@ from sentry.api.bases.project import ProjectEndpoint, ProjectEventPermission from sentry.api.serializers import serialize from sentry.models.groupinbox import GroupInboxReason, add_group_to_inbox +from sentry.models.project import Project from sentry.utils.samples import create_sample_event # we have a more modern python example for python @@ -27,11 +28,15 @@ class ProjectCreateSampleEndpoint(ProjectEndpoint): # This is the same scope that allows members to view all issues for a project. permission_classes = (ProjectEventPermission,) - def post(self, request: Request, project) -> Response: + def post(self, request: Request, project: Project) -> Response: try: event = create_sample_event( project, - platform=PLATFORM_MAPPING.get(project.platform, project.platform), + platform=( + PLATFORM_MAPPING.get(project.platform, project.platform) + if project.platform is not None + else project.platform + ), default="javascript", tagged=True, ) diff --git a/src/sentry/apidocs/parameters.py b/src/sentry/apidocs/parameters.py index f94179f1ade6..44f9e83f041b 100644 --- a/src/sentry/apidocs/parameters.py +++ b/src/sentry/apidocs/parameters.py @@ -39,7 +39,7 @@ def mark_parameter_experimental(parameter: OpenApiParameter) -> OpenApiParameter return parameter -def build_typed_list(type: Any): +def build_typed_list(type: Any) -> dict[str, Any]: """ drf-spectacular doesn't support a list type in it's OpenApiTypes, so we manually build a typed list using this workaround. build_basic_type will dynamically check the type diff --git a/src/sentry/db/models/base.py b/src/sentry/db/models/base.py index bcbdf9711c4e..83bfbdf51a58 100644 --- a/src/sentry/db/models/base.py +++ b/src/sentry/db/models/base.py @@ -91,7 +91,9 @@ def __reduce__( def __setstate__(self, state: Mapping[str, Any]) -> None: self.__dict__.update(state) - def _get_relational_field(self, field_name: str) -> models.ForeignKey: + def _get_relational_field( + self, field_name: str + ) -> models.ForeignKey[models.Model, models.Model]: ret = self._meta.get_field(field_name) if not isinstance(ret, models.ForeignKey): raise TypeError(f"expected {field_name=} to be ForeignKey") diff --git a/src/sentry/grouping/enhancer/__init__.py b/src/sentry/grouping/enhancer/__init__.py index c5432893a4ec..6bd5f6ae9cc7 100644 --- a/src/sentry/grouping/enhancer/__init__.py +++ b/src/sentry/grouping/enhancer/__init__.py @@ -193,7 +193,7 @@ def _add_rule_source_to_hint(hint: str | None, custom_rules: set[str]) -> str | if not hint: return None - def _add_type_to_rule(rule_regex_match: re.Match) -> str: + def _add_type_to_rule(rule_regex_match: re.Match[str]) -> str: rule_str = rule_regex_match.group(1) rule_type = "custom" if rule_str in custom_rules else "built-in" return f"{rule_type} stacktrace rule ({rule_str})" diff --git a/src/sentry/integrations/api/endpoints/organization_repositories.py b/src/sentry/integrations/api/endpoints/organization_repositories.py index 7e23d13cdd04..71dcf2b992ff 100644 --- a/src/sentry/integrations/api/endpoints/organization_repositories.py +++ b/src/sentry/integrations/api/endpoints/organization_repositories.py @@ -136,7 +136,7 @@ def get( count_hits=True, ) - def post(self, request: Request, organization) -> Response: + def post(self, request: Request, organization: Organization) -> Response: if not request.user.is_authenticated: return Response(status=401) provider_id = request.data.get("provider") diff --git a/src/sentry/integrations/github_enterprise/repository.py b/src/sentry/integrations/github_enterprise/repository.py index 010eae12ba21..e7d16429d18e 100644 --- a/src/sentry/integrations/github_enterprise/repository.py +++ b/src/sentry/integrations/github_enterprise/repository.py @@ -1,6 +1,8 @@ from collections.abc import Mapping from typing import Any +from sentry.integrations.base import IntegrationInstallation +from sentry.integrations.github.client import GitHubBaseClient from sentry.integrations.github.repository import GitHubRepositoryProvider from sentry.integrations.services.integration import integration_service from sentry.integrations.types import IntegrationProviderSlug @@ -18,7 +20,9 @@ class GitHubEnterpriseRepositoryProvider(GitHubRepositoryProvider): # github enterprise has non-unique repository IDs so we'll have to handle transfers differently can_transfer_repositories = False - def _validate_repo(self, client, installation, repo): + def _validate_repo( + self, client: GitHubBaseClient, installation: IntegrationInstallation, repo: str + ) -> Mapping[str, Any]: try: repo_data = client.get_repo(repo) except Exception as e: diff --git a/src/sentry/integrations/models/utils.py b/src/sentry/integrations/models/utils.py index f81a2faaff9e..601967c674e1 100644 --- a/src/sentry/integrations/models/utils.py +++ b/src/sentry/integrations/models/utils.py @@ -35,5 +35,5 @@ def has_feature(instance: Integration | RpcIntegration, feature: IntegrationFeat return feature in instance.get_provider().features -def get_redis_key(sentryapp: SentryApp | RpcSentryApp, org_id) -> str: +def get_redis_key(sentryapp: SentryApp | RpcSentryApp, org_id: int) -> str: return f"sentry-app-error:{sentryapp.id}:{org_id}" diff --git a/src/sentry/integrations/slack/workspace.py b/src/sentry/integrations/slack/workspace.py index 6c2cb0e21d40..5438999a4286 100644 --- a/src/sentry/integrations/slack/workspace.py +++ b/src/sentry/integrations/slack/workspace.py @@ -26,7 +26,7 @@ _logger = logging.getLogger("sentry.integrations.slack") -def get_conversations_info(*, integration_id: int, channel_id: str) -> dict: +def get_conversations_info(*, integration_id: int, channel_id: str) -> dict[str, Any]: """Fetch conversations.info. Returns an empty dict on failure.""" try: client = SlackSdkClient(integration_id=integration_id) diff --git a/src/sentry/issues/endpoints/event_owners.py b/src/sentry/issues/endpoints/event_owners.py index 1b969c851f11..9e9f3f97cf47 100644 --- a/src/sentry/issues/endpoints/event_owners.py +++ b/src/sentry/issues/endpoints/event_owners.py @@ -10,6 +10,7 @@ from sentry.api.bases.project import ProjectEndpoint from sentry.api.serializers import serialize from sentry.api.serializers.models.actor import ActorSerializer +from sentry.models.project import Project from sentry.models.projectownership import ProjectOwnership from sentry.services import eventstore from sentry.types.actor import Actor @@ -22,7 +23,7 @@ class EventOwnersEndpoint(ProjectEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, project, event_id) -> Response: + def get(self, request: Request, project: Project, event_id: str) -> Response: """ Retrieve suggested owners information for an event `````````````````````````````````````````````````` diff --git a/src/sentry/issues/endpoints/group_integrations.py b/src/sentry/issues/endpoints/group_integrations.py index 30693837aa8b..289a55ff1b00 100644 --- a/src/sentry/issues/endpoints/group_integrations.py +++ b/src/sentry/issues/endpoints/group_integrations.py @@ -137,7 +137,9 @@ class GroupIntegrationsEndpoint(GroupEndpoint): suggested_api="sentry-api-0-organization-group-group-integrations", url_names=["sentry-api-0-group-integrations"], ) - def get(self, request: Request, group) -> Response[list[IntegrationIssueSerializerResponse]]: + def get( + self, request: Request, group: Group + ) -> Response[list[IntegrationIssueSerializerResponse]]: """ List the organization's issue-tracker integrations and the external issues linked to this Sentry issue through each integration. The external issue's diff --git a/src/sentry/issues/endpoints/group_tagkey_details.py b/src/sentry/issues/endpoints/group_tagkey_details.py index 3df61a18a28a..d7b1fc1d13b8 100644 --- a/src/sentry/issues/endpoints/group_tagkey_details.py +++ b/src/sentry/issues/endpoints/group_tagkey_details.py @@ -22,6 +22,7 @@ from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.issues.endpoints.bases.group import GroupEndpoint from sentry.models.environment import Environment +from sentry.models.group import Group from sentry.ratelimits.config import RateLimitConfig from sentry.tagstore.types import TagKeySerializer, TagKeySerializerResponse from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -71,7 +72,7 @@ class GroupTagKeyDetailsEndpoint(GroupEndpoint): suggested_api="sentry-api-0-organization-group-group-tag-key-details", url_names=["sentry-api-0-group-tag-key-details"], ) - def get(self, request: Request, group, key) -> Response[TagKeySerializerResponse]: + def get(self, request: Request, group: Group, key: str) -> Response[TagKeySerializerResponse]: """ Returns the values and aggregate details of a given tag key related to an issue. """ diff --git a/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py b/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py index c141a9e6ca10..c59dbc5be1c3 100644 --- a/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py +++ b/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py @@ -1,3 +1,5 @@ +from typing import Any + from rest_framework import serializers, status from rest_framework.request import Request from rest_framework.response import Response @@ -16,7 +18,7 @@ class StarViewSerializer(serializers.Serializer): starred = serializers.BooleanField(required=True) position = serializers.IntegerField(required=False) - def validate(self, data): + def validate(self, data: dict[str, Any]) -> dict[str, Any]: if not data["starred"] and "position" in data: raise serializers.ValidationError("Position is only allowed when starring a view.") return data diff --git a/src/sentry/issues/escalating/issue_velocity.py b/src/sentry/issues/escalating/issue_velocity.py index 0ce20c45892e..66a167d44007 100644 --- a/src/sentry/issues/escalating/issue_velocity.py +++ b/src/sentry/issues/escalating/issue_velocity.py @@ -255,5 +255,5 @@ def get_latest_threshold(project: Project) -> float: return threshold -def get_redis_client() -> RedisCluster | StrictRedis: +def get_redis_client() -> RedisCluster[str] | StrictRedis[str]: return redis_clusters.get(settings.SENTRY_ESCALATION_THRESHOLDS_REDIS_CLUSTER) diff --git a/src/sentry/middleware/ai_agent.py b/src/sentry/middleware/ai_agent.py index 8e866f4afdde..c56972f26dae 100644 --- a/src/sentry/middleware/ai_agent.py +++ b/src/sentry/middleware/ai_agent.py @@ -3,6 +3,7 @@ import json # noqa: S003 - need stdlib json for indent support import logging import re +from collections.abc import Callable from typing import Any from django.http import HttpRequest, HttpResponse @@ -104,7 +105,7 @@ class AIAgentMiddleware: 3. Request is anonymous (no authenticated user, no auth token) """ - def __init__(self, get_response): + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): self.get_response = get_response def __call__(self, request: HttpRequest) -> HttpResponse: diff --git a/src/sentry/models/dashboard_permissions.py b/src/sentry/models/dashboard_permissions.py index 21df9dad4a4b..08248713956c 100644 --- a/src/sentry/models/dashboard_permissions.py +++ b/src/sentry/models/dashboard_permissions.py @@ -37,7 +37,7 @@ class DashboardPermissions(Model): "sentry.Dashboard", on_delete=models.CASCADE, related_name="permissions" ) - def has_edit_permissions(self, user_id): + def has_edit_permissions(self, user_id: int) -> bool: if self.is_editable_by_everyone: return True if user_id == self.dashboard.created_by_id: diff --git a/src/sentry/models/groupopenperiodactivity.py b/src/sentry/models/groupopenperiodactivity.py index c9ec89e19073..2965b505d2b7 100644 --- a/src/sentry/models/groupopenperiodactivity.py +++ b/src/sentry/models/groupopenperiodactivity.py @@ -34,7 +34,7 @@ class GroupOpenPeriodActivity(DefaultFieldsModel): group_open_period = FlexibleForeignKey("sentry.GroupOpenPeriod") # OpenPeriodActivityType - type: models.Field = models.IntegerField() + type: models.Field[int, int] = models.IntegerField() # The priority associated with this activity row. # Can be None if the row corresponds to open period closure. value = models.IntegerField(null=True) diff --git a/src/sentry/relay/config/experimental.py b/src/sentry/relay/config/experimental.py index ee7cf165e85d..f3f875864e6c 100644 --- a/src/sentry/relay/config/experimental.py +++ b/src/sentry/relay/config/experimental.py @@ -39,7 +39,7 @@ def check(self) -> None: class ExperimentalConfigBuilder(Protocol): - def __call__(self, timeout: TimeChecker, *args, **kwargs) -> Any: + def __call__(self, timeout: TimeChecker, *args: Any, **kwargs: Any) -> Any: pass diff --git a/src/sentry/search/utils.py b/src/sentry/search/utils.py index 8784a9133216..ed6edc2a444f 100644 --- a/src/sentry/search/utils.py +++ b/src/sentry/search/utils.py @@ -376,7 +376,7 @@ def get_latest_release( projects: Sequence[Project | int], environments: Sequence[Environment] | None, organization_id: int | None = None, - adopted=False, + adopted: bool = False, ) -> list[str]: if organization_id is None: project = projects[0] diff --git a/src/sentry/tasks/clear_expired_resolutions.py b/src/sentry/tasks/clear_expired_resolutions.py index fca1e4ffc620..58da4a37101a 100644 --- a/src/sentry/tasks/clear_expired_resolutions.py +++ b/src/sentry/tasks/clear_expired_resolutions.py @@ -21,7 +21,7 @@ processing_deadline_duration=15, silo_mode=SiloMode.CELL, ) -def clear_expired_resolutions(release_id): +def clear_expired_resolutions(release_id: int) -> None: """ This should be fired when ``release_id`` is created or its dates change, and will indicate to the system that any pending resolutions older than the given release can now diff --git a/src/sentry/tasks/seer/autofix.py b/src/sentry/tasks/seer/autofix.py index 4f5560ba18f6..67ce983b2b9e 100644 --- a/src/sentry/tasks/seer/autofix.py +++ b/src/sentry/tasks/seer/autofix.py @@ -1,4 +1,5 @@ import logging +from typing import Any import sentry_sdk from django.contrib.auth.models import AnonymousUser @@ -69,7 +70,7 @@ def _get_group_or_log(group_id: int, task_name: str) -> Group | None: processing_deadline_duration=35, retry=Retry(times=1), ) -def generate_summary_and_run_automation(group_id: int, **kwargs) -> None: +def generate_summary_and_run_automation(group_id: int, **kwargs: Any) -> None: trigger_path = kwargs.get("trigger_path", "unknown") sentry_sdk.set_tag("trigger_path", trigger_path) sentry_sdk.set_attribute("trigger_path", trigger_path) diff --git a/src/sentry/testutils/helpers/redis.py b/src/sentry/testutils/helpers/redis.py index 4d7bed1f64eb..a55c65f9e1b7 100644 --- a/src/sentry/testutils/helpers/redis.py +++ b/src/sentry/testutils/helpers/redis.py @@ -1,7 +1,7 @@ from collections.abc import Generator from contextlib import contextmanager from typing import Any -from unittest.mock import patch +from unittest.mock import _patch, patch from django.test.utils import override_settings @@ -10,7 +10,7 @@ from sentry.testutils.helpers import override_options -def mock_redis_buffer(): +def mock_redis_buffer() -> _patch[RedisBuffer]: return patch("sentry.buffer.backend", new=RedisBuffer()) diff --git a/src/sentry/uptime/checker_api.py b/src/sentry/uptime/checker_api.py index 8f9fc37dc95b..e55ab2625301 100644 --- a/src/sentry/uptime/checker_api.py +++ b/src/sentry/uptime/checker_api.py @@ -1,15 +1,28 @@ import uuid +from collections.abc import Sequence +from typing import NotRequired, TypedDict import requests from sentry.conf.types.uptime import UptimeRegionConfig from sentry.uptime.models import UptimeRegionScheduleMode -from sentry.uptime.types import CheckConfig +from sentry.uptime.types import CheckConfig, RequestHeader, RequestMethod + + +class _PreviewCheckData(TypedDict): + url: str + timeout_ms: int | float + method: NotRequired[RequestMethod] + headers: NotRequired[Sequence[RequestHeader]] + body: NotRequired[str] + assertion: NotRequired[object] # Create a "preview check" that we send to an uptime checker to validate the config # (in particular, the assertion.) -def create_preview_check(validated_data, region: UptimeRegionConfig) -> CheckConfig: +def create_preview_check( + validated_data: _PreviewCheckData, region: UptimeRegionConfig +) -> CheckConfig: config: CheckConfig = { "subscription_id": uuid.UUID(int=0).hex, "url": validated_data.get("url"), @@ -21,17 +34,21 @@ def create_preview_check(validated_data, region: UptimeRegionConfig) -> CheckCon "region_schedule_mode": UptimeRegionScheduleMode.ROUND_ROBIN.value, } - if validated_data.get("method") is not None: - config["request_method"] = validated_data.get("method") + method = validated_data.get("method") + if method is not None: + config["request_method"] = method - if validated_data.get("headers") is not None: - config["request_headers"] = validated_data.get("headers") + headers = validated_data.get("headers") + if headers is not None: + config["request_headers"] = headers - if validated_data.get("body") is not None: - config["request_body"] = validated_data.get("body") + body = validated_data.get("body") + if body is not None: + config["request_body"] = body - if validated_data.get("assertion") is not None: - config["assertion"] = validated_data.get("assertion") + assertion = validated_data.get("assertion") + if assertion is not None: + config["assertion"] = assertion return config diff --git a/src/sentry/web/frontend/auth_channel_login.py b/src/sentry/web/frontend/auth_channel_login.py index b646de00ac4d..fce17b41e418 100644 --- a/src/sentry/web/frontend/auth_channel_login.py +++ b/src/sentry/web/frontend/auth_channel_login.py @@ -1,4 +1,7 @@ +from typing import Any + from django.contrib.auth import REDIRECT_FIELD_NAME +from django.http import HttpRequest, HttpResponseBase from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache @@ -15,11 +18,13 @@ @control_silo_view class AuthChannelLoginView(AuthOrganizationLoginView): @method_decorator(never_cache) - def handle(self, request, channel, resource_id): # type: ignore[override] # intentional signature change + def handle( # type: ignore[override] # intentional signature change + self, request: HttpRequest, channel: str, resource_id: str + ) -> HttpResponseBase: if request.subdomain is not None: return self.redirect(reverse("sentry-auth-organization", args=[request.subdomain])) - config_data = request.GET.get("config_data", "") + config_data: str | dict[str, Any] = request.GET.get("config_data", "") if not config_data: # NOTE: This provider_config may differ per provider diff --git a/src/sentry/web/frontend/group_tag_export.py b/src/sentry/web/frontend/group_tag_export.py index 4c04f29821cc..c46bcb661566 100644 --- a/src/sentry/web/frontend/group_tag_export.py +++ b/src/sentry/web/frontend/group_tag_export.py @@ -11,6 +11,8 @@ IssuesByTagProcessor, ) from sentry.models.environment import Environment +from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.ratelimits.config import RateLimitConfig from sentry.types.ratelimit import RateLimit, RateLimitCategory from sentry.web.frontend.base import ProjectView, cell_silo_view @@ -45,7 +47,14 @@ class GroupTagExportView(ProjectView): } ) - def get(self, request: Request, organization, project, group_id, key) -> HttpResponseBase: + def get( + self, + request: Request, + organization: Organization, + project: Project, + group_id: int | str, + key: str, + ) -> HttpResponseBase: # If the environment doesn't exist then the tag can't possibly exist try: environment_id = get_environment_id(request, project.organization_id) diff --git a/src/sentry/web/frontend/twofactor.py b/src/sentry/web/frontend/twofactor.py index 83249ffa5f4f..1789fd7147a0 100644 --- a/src/sentry/web/frontend/twofactor.py +++ b/src/sentry/web/frontend/twofactor.py @@ -20,7 +20,7 @@ def handle(self, request: HttpRequest) -> HttpResponse: @control_silo_view -def u2f_appid(request): +def u2f_appid(request: HttpRequest) -> HttpResponse: facets = options.get("u2f.facets") if not facets: facets = [options.get("system.url-prefix")] From 18ba4f395d618ccb2b1e31ea39643a0e5e40a245 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:52:27 -0700 Subject: [PATCH 023/103] ref(typing): graduate lane 3 weaklist modules Add precise annotations so each module passes strict mypy without its weaklist override. The uptime consumer tuple annotation follows the now-typed redis client. --- src/sentry/api/endpoints/organization_insights_tree.py | 2 +- src/sentry/api/endpoints/project_symbol_sources.py | 2 +- src/sentry/apidocs/schema.py | 4 ++-- src/sentry/explore/models.py | 3 ++- .../api/endpoints/organization_integration_request.py | 4 +++- .../api/endpoints/organization_repository_commits.py | 3 ++- src/sentry/integrations/models/data_forwarder_project.py | 2 +- src/sentry/integrations/msteams/unlink_identity.py | 2 +- src/sentry/issues/endpoints/event_reprocessable.py | 3 ++- src/sentry/issues/endpoints/group_reprocessing.py | 3 ++- src/sentry/issues/endpoints/group_tagkey_values.py | 5 ++++- .../organization_group_search_view_starred_order.py | 2 +- src/sentry/issues/grouptype.py | 2 +- src/sentry/middleware/auth.py | 8 +++++--- src/sentry/models/grouphash.py | 2 +- src/sentry/models/organizationonboardingtask.py | 2 +- src/sentry/replays/models.py | 6 ++++-- src/sentry/seer/issue_detection.py | 2 +- src/sentry/tasks/console_platform_cleanup.py | 2 +- src/sentry/tasks/seer/explorer_index.py | 2 +- src/sentry/testutils/pytest/mocking.py | 8 ++++---- src/sentry/uptime/consumers/tasks.py | 2 +- src/sentry/uptime/utils.py | 2 +- src/sentry/web/frontend/base.py | 2 +- src/sentry/web/frontend/idp_email_verification.py | 5 ++++- src/social_auth/django_compat.py | 4 +++- 26 files changed, 51 insertions(+), 33 deletions(-) diff --git a/src/sentry/api/endpoints/organization_insights_tree.py b/src/sentry/api/endpoints/organization_insights_tree.py index a0dbf053fbd7..a2aec07fd246 100644 --- a/src/sentry/api/endpoints/organization_insights_tree.py +++ b/src/sentry/api/endpoints/organization_insights_tree.py @@ -78,7 +78,7 @@ def _parse_path(path: str | None) -> list[str]: parts = path.strip("/").split("/") return parts if parts != [""] else [] - def _separate_span_description_info(self, response): + def _separate_span_description_info(self, response: Response) -> Response: for line in response.data["data"]: component_type = None path_components: list[str] = [] diff --git a/src/sentry/api/endpoints/project_symbol_sources.py b/src/sentry/api/endpoints/project_symbol_sources.py index ac12e63380e0..136bf5620d9c 100644 --- a/src/sentry/api/endpoints/project_symbol_sources.py +++ b/src/sentry/api/endpoints/project_symbol_sources.py @@ -228,7 +228,7 @@ class SourceSerializer(serializers.Serializer): help_text="The Microsoft Entra client secret. Required for Azure sources, invalid for all others.", ) - def validate(self, data): + def validate(self, data: dict[str, object]) -> dict[str, object]: if data["type"] == "http": required = ["type", "name", "url", "layout"] allowed = required + ["username", "password"] diff --git a/src/sentry/apidocs/schema.py b/src/sentry/apidocs/schema.py index edd0b1482180..6c403e2ea0e2 100644 --- a/src/sentry/apidocs/schema.py +++ b/src/sentry/apidocs/schema.py @@ -1,6 +1,6 @@ import contextlib import copy -from collections.abc import Iterator +from collections.abc import Callable, Iterator from typing import Any from drf_spectacular.drainage import get_override @@ -25,7 +25,7 @@ class SentrySchema(AutoSchema): """DRF Documentation Schema for sentry endpoints""" @property - def view_func(self): + def view_func(self) -> Callable[..., object]: return getattr(self.view, self.method.lower()) def get_operation_id(self) -> str: diff --git a/src/sentry/explore/models.py b/src/sentry/explore/models.py index 3f1b7b9dfc61..8b79dbdd19b0 100644 --- a/src/sentry/explore/models.py +++ b/src/sentry/explore/models.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterable from typing import ClassVar, Literal from django.contrib.postgres.fields.array import ArrayField @@ -111,7 +112,7 @@ class Meta: __repr__ = sane_repr("organization_id", "created_by_id", "name") - def set_projects(self, project_ids): + def set_projects(self, project_ids: Iterable[int]) -> None: with transaction.atomic(router.db_for_write(ExploreSavedQueryProject)): ExploreSavedQueryProject.objects.filter(explore_saved_query=self).exclude( project__in=project_ids diff --git a/src/sentry/integrations/api/endpoints/organization_integration_request.py b/src/sentry/integrations/api/endpoints/organization_integration_request.py index 212a35b91c25..fe133caa271f 100644 --- a/src/sentry/integrations/api/endpoints/organization_integration_request.py +++ b/src/sentry/integrations/api/endpoints/organization_integration_request.py @@ -19,6 +19,8 @@ if TYPE_CHECKING: from django.utils.functional import _StrPromise + from sentry.models.organization import Organization + def get_provider_name(provider_type: str, provider_slug: str) -> str | _StrPromise | None: """ @@ -49,7 +51,7 @@ class OrganizationIntegrationRequestEndpoint(OrganizationRequestChangeEndpoint): "POST": ApiPublishStatus.PRIVATE, } - def post(self, request: Request, organization) -> Response: + def post(self, request: Request, organization: Organization) -> Response: """ Email the organization owners asking them to install an integration. ```````````````````````````````````````````````````````````````````` diff --git a/src/sentry/integrations/api/endpoints/organization_repository_commits.py b/src/sentry/integrations/api/endpoints/organization_repository_commits.py index 6f9d389a6172..9f1337782bcb 100644 --- a/src/sentry/integrations/api/endpoints/organization_repository_commits.py +++ b/src/sentry/integrations/api/endpoints/organization_repository_commits.py @@ -20,6 +20,7 @@ from sentry.apidocs.parameters import CursorQueryParam, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.commit import Commit +from sentry.models.organization import Organization from sentry.models.repository import Repository @@ -71,7 +72,7 @@ class OrganizationRepositoryCommitsEndpoint(OrganizationEndpoint): ], ) def get( - self, request: Request, organization, repo_id + self, request: Request, organization: Organization, repo_id: int | str ) -> Response[list[CommitSerializerResponse]]: """ List a Repository's Commits diff --git a/src/sentry/integrations/models/data_forwarder_project.py b/src/sentry/integrations/models/data_forwarder_project.py index 1ff250c40cde..98a6fc91da9c 100644 --- a/src/sentry/integrations/models/data_forwarder_project.py +++ b/src/sentry/integrations/models/data_forwarder_project.py @@ -29,7 +29,7 @@ class Meta: db_table = "sentry_dataforwarderproject" unique_together = (("data_forwarder", "project"),) - def get_config(self) -> dict: + def get_config(self) -> dict[str, str]: """ Get the configuration by merging the base config with project overrides. """ diff --git a/src/sentry/integrations/msteams/unlink_identity.py b/src/sentry/integrations/msteams/unlink_identity.py index 9f68829b8d04..175dbff083b3 100644 --- a/src/sentry/integrations/msteams/unlink_identity.py +++ b/src/sentry/integrations/msteams/unlink_identity.py @@ -14,7 +14,7 @@ from .utils import get_preinstall_client -def build_unlinking_url(conversation_id, service_url, teams_user_id): +def build_unlinking_url(conversation_id: str, service_url: str, teams_user_id: str) -> str: signed_params = sign( salt=SALT, conversation_id=conversation_id, diff --git a/src/sentry/issues/endpoints/event_reprocessable.py b/src/sentry/issues/endpoints/event_reprocessable.py index e863fa8d989b..756177817a59 100644 --- a/src/sentry/issues/endpoints/event_reprocessable.py +++ b/src/sentry/issues/endpoints/event_reprocessable.py @@ -6,6 +6,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint +from sentry.models.project import Project from sentry.reprocessing2 import CannotReprocess, pull_event_data @@ -16,7 +17,7 @@ class EventReprocessableEndpoint(ProjectEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, project, event_id) -> Response: + def get(self, request: Request, project: Project, event_id: str) -> Response: """ Retrieve information about whether an event can be reprocessed. ``````````````````````````````````````````````````````````````` diff --git a/src/sentry/issues/endpoints/group_reprocessing.py b/src/sentry/issues/endpoints/group_reprocessing.py index 45a83ac16691..4ae115895ad4 100644 --- a/src/sentry/issues/endpoints/group_reprocessing.py +++ b/src/sentry/issues/endpoints/group_reprocessing.py @@ -6,6 +6,7 @@ from sentry.api.helpers.deprecation import deprecated from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.issues.endpoints.bases.group import GroupEndpoint +from sentry.models.group import Group from sentry.tasks.reprocessing2 import reprocess_group @@ -20,7 +21,7 @@ class GroupReprocessingEndpoint(GroupEndpoint): suggested_api="sentry-api-0-organization-group-group-reprocessing", url_names=["sentry-api-0-group-reprocessing"], ) - def post(self, request: Request, group) -> Response: + def post(self, request: Request, group: Group) -> Response: """ Reprocess a group ````````````````` diff --git a/src/sentry/issues/endpoints/group_tagkey_values.py b/src/sentry/issues/endpoints/group_tagkey_values.py index 7f34e1a9d037..0cdddd76d4e0 100644 --- a/src/sentry/issues/endpoints/group_tagkey_values.py +++ b/src/sentry/issues/endpoints/group_tagkey_values.py @@ -23,6 +23,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.issues.endpoints.bases.group import GroupEndpoint +from sentry.models.group import Group from sentry.ratelimits.config import RateLimitConfig from sentry.tagstore.types import TagValueSerializerResponse from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -75,7 +76,9 @@ class GroupTagKeyValuesEndpoint(GroupEndpoint): suggested_api="sentry-api-0-organization-group-group-tag-key-values", url_names=["sentry-api-0-group-tag-key-values"], ) - def get(self, request: Request, group, key) -> Response[list[TagValueSerializerResponse]]: + def get( + self, request: Request, group: Group, key: str + ) -> Response[list[TagValueSerializerResponse]]: """ List a Tag's Values """ diff --git a/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py b/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py index 03d4ce4d19bb..87e57e3858ac 100644 --- a/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py +++ b/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py @@ -20,7 +20,7 @@ class MemberPermission(OrganizationPermission): class GroupSearchViewStarredOrderSerializer(serializers.Serializer): view_ids = serializers.ListField(child=serializers.IntegerField(), required=True, min_length=0) - def validate_view_ids(self, view_ids): + def validate_view_ids(self, view_ids: list[int]) -> list[int]: if len(view_ids) != len(set(view_ids)): raise serializers.ValidationError("Single view cannot take up multiple positions") diff --git a/src/sentry/issues/grouptype.py b/src/sentry/issues/grouptype.py index 91243fcc5586..4a29500ca155 100644 --- a/src/sentry/issues/grouptype.py +++ b/src/sentry/issues/grouptype.py @@ -801,7 +801,7 @@ class WebVitalsGroup(GroupType): # TODO: Rename to WebVitalsGroupType def should_create_group( grouptype: type[GroupType], - client: RedisCluster | StrictRedis, + client: RedisCluster[str] | StrictRedis[str], grouphash: str, project: Project, ) -> bool: diff --git a/src/sentry/middleware/auth.py b/src/sentry/middleware/auth.py index e2fc08d87bf8..f4bbb0ac8924 100644 --- a/src/sentry/middleware/auth.py +++ b/src/sentry/middleware/auth.py @@ -20,6 +20,7 @@ ViewerContextAuthentication, ) from sentry.auth.principal import set_request_principal +from sentry.users.models.user import User from sentry.users.models.userip import UserIP from sentry.utils.auth import AuthUserPasswordExpired, logger @@ -33,7 +34,7 @@ ) -def get_user(request): +def get_user(request: HttpRequest) -> User | AnonymousUser: if not hasattr(request, "_cached_user"): user = auth_get_user(request) # If the user bound to this request matches a real user, @@ -60,8 +61,9 @@ def get_user(request): user = AnonymousUser() else: UserIP.log(user, request.META["REMOTE_ADDR"]) - request._cached_user = user - return request._cached_user + setattr(request, "_cached_user", user) + cached_user: User | AnonymousUser = getattr(request, "_cached_user") + return cached_user class AuthenticationMiddleware(MiddlewareMixin): diff --git a/src/sentry/models/grouphash.py b/src/sentry/models/grouphash.py index 20ca890dafe2..d0611bd6c6ce 100644 --- a/src/sentry/models/grouphash.py +++ b/src/sentry/models/grouphash.py @@ -37,7 +37,7 @@ # Django signals we can listen for (see end of file). We also don't have to override the model's # `update` method because our base model class already does that, with a method that calls the # queryset's `update`. -class GroupHashQuerySet(BaseQuerySet): +class GroupHashQuerySet(BaseQuerySet["GroupHash"]): def update(self, **kwargs: Any) -> int: if not options.get("grouping.use_ingest_grouphash_caching"): return super().update(**kwargs) diff --git a/src/sentry/models/organizationonboardingtask.py b/src/sentry/models/organizationonboardingtask.py index f85438f805dd..bf7480830753 100644 --- a/src/sentry/models/organizationonboardingtask.py +++ b/src/sentry/models/organizationonboardingtask.py @@ -62,7 +62,7 @@ def record( organization_id: int, task: int, status: OnboardingTaskStatus = OnboardingTaskStatus.COMPLETE, - **kwargs, + **kwargs: object, ) -> bool: """Record the completion of an onboarding task. Caches the completion. Returns whether the task was created or not.""" if status != OnboardingTaskStatus.COMPLETE: diff --git a/src/sentry/replays/models.py b/src/sentry/replays/models.py index eb3c8be947a5..312bec37999d 100644 --- a/src/sentry/replays/models.py +++ b/src/sentry/replays/models.py @@ -63,7 +63,9 @@ class Meta: __repr__ = sane_repr("replay_id", "segment_id", "file_id") - def delete(self, *args, **kwargs): + def delete( + self, using: object | None = None, keep_parents: bool = False + ) -> tuple[int, dict[str, int]]: from sentry.models.files.file import File try: @@ -77,7 +79,7 @@ def delete(self, *args, **kwargs): else: file.delete() - rv = super().delete(*args, **kwargs) + rv = super().delete(using=using, keep_parents=keep_parents) return rv diff --git a/src/sentry/seer/issue_detection.py b/src/sentry/seer/issue_detection.py index 13aa8e920acb..4907b608ecc0 100644 --- a/src/sentry/seer/issue_detection.py +++ b/src/sentry/seer/issue_detection.py @@ -14,7 +14,7 @@ def create_issue_occurrence( *, organization_id: int, project_id: int, - detected_issue: dict, + detected_issue: dict[str, object], ) -> CreateIssueOccurrenceResponse: """ Create an issue occurrence from Seer detection data. diff --git a/src/sentry/tasks/console_platform_cleanup.py b/src/sentry/tasks/console_platform_cleanup.py index 67bbbf8b460d..f83d8544738c 100644 --- a/src/sentry/tasks/console_platform_cleanup.py +++ b/src/sentry/tasks/console_platform_cleanup.py @@ -17,7 +17,7 @@ silo_mode=SiloMode.CELL, ) def remove_inaccessible_console_platform_sources( - organization_id: int, current_platforms: list[str], **kwargs + organization_id: int, current_platforms: list[str], **kwargs: object ) -> None: """ Remove symbol sources that the organization can no longer access from all diff --git a/src/sentry/tasks/seer/explorer_index.py b/src/sentry/tasks/seer/explorer_index.py index 131aaa1c903d..f4d01167e266 100644 --- a/src/sentry/tasks/seer/explorer_index.py +++ b/src/sentry/tasks/seer/explorer_index.py @@ -190,7 +190,7 @@ def dispatch_explorer_index_projects( processing_deadline_duration=60, ) def run_explorer_index_for_projects( - projects: list[tuple[int, int]], start: str, *args, **kwargs + projects: list[tuple[int, int]], start: str, *args: object, **kwargs: object ) -> None: """ Call the seer /v1/automation/explorer/index endpoint to schedule indexing tasks diff --git a/src/sentry/testutils/pytest/mocking.py b/src/sentry/testutils/pytest/mocking.py index 77fc22f636ca..166c61606f6c 100644 --- a/src/sentry/testutils/pytest/mocking.py +++ b/src/sentry/testutils/pytest/mocking.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any, ParamSpec, TypeVar +from typing import Any, ParamSpec, TypeVar, cast from unittest.mock import MagicMock from unittest.mock import call as MockCall @@ -107,11 +107,11 @@ def test_getting_dog_with_error(): assert repr(result)== "TypeError('Expected dog, but got cat instead.')" """ - def record_result(result): + def record_result(result: T | Exception) -> None: if isinstance(results, list): - results.append(result) + results.append(cast(T, result)) elif isinstance(results, dict): - results.setdefault(fn.__name__, []).append(result) + results.setdefault(fn.__name__, []).append(cast(T, result)) def wrapped_fn(*args: P.args, **kwargs: P.kwargs) -> T: try: diff --git a/src/sentry/uptime/consumers/tasks.py b/src/sentry/uptime/consumers/tasks.py index d5f07a4f64c5..a12423491d1f 100644 --- a/src/sentry/uptime/consumers/tasks.py +++ b/src/sentry/uptime/consumers/tasks.py @@ -69,7 +69,7 @@ def process_uptime_backlog(subscription_id: str, attempt: int = 1): subscription_interval_ms = subscription.interval_seconds * 1000 expected_next_ms = last_update_ms + subscription_interval_ms processed_count = 0 - queued_results_raw: list[tuple[bytes, float]] = cluster.zrange( + queued_results_raw: list[tuple[str, float]] = cluster.zrange( backlog_key, 0, -1, withscores=True ) host_provider = get_host_provider_if_valid(subscription) diff --git a/src/sentry/uptime/utils.py b/src/sentry/uptime/utils.py index 1f14edffc062..e5faf26dbf48 100644 --- a/src/sentry/uptime/utils.py +++ b/src/sentry/uptime/utils.py @@ -37,7 +37,7 @@ def build_backlog_schedule_lock_key(subscription_id: str) -> str: return f"uptime:backlog_schedule_lock:{subscription_id}" -def get_cluster() -> RedisCluster | StrictRedis: +def get_cluster() -> "RedisCluster[str] | StrictRedis[str]": return redis.redis_clusters.get(settings.SENTRY_UPTIME_DETECTOR_CLUSTER) diff --git a/src/sentry/web/frontend/base.py b/src/sentry/web/frontend/base.py index bf622c7a6d88..5deec2f19e47 100644 --- a/src/sentry/web/frontend/base.py +++ b/src/sentry/web/frontend/base.py @@ -129,7 +129,7 @@ def __call__(self, decorated_obj: Any) -> Any: class CellSiloView(ViewSiloLimit): def __init__( - self, cell_resolver: CellRequestResolver | None = None, *args, **kwargs: Any + self, cell_resolver: CellRequestResolver | None = None, *args: bool, **kwargs: Any ) -> None: super().__init__([SiloMode.CELL], *args, **kwargs) self.cell_resolver = cell_resolver diff --git a/src/sentry/web/frontend/idp_email_verification.py b/src/sentry/web/frontend/idp_email_verification.py index cdcd24aba215..aef1384d74b0 100644 --- a/src/sentry/web/frontend/idp_email_verification.py +++ b/src/sentry/web/frontend/idp_email_verification.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Mapping from django.http import HttpRequest from django.http.response import HttpResponse @@ -46,7 +47,9 @@ def handle(self, request: HttpRequest, key: str) -> HttpResponse: return render_to_response("sentry/idp_account_not_verified.html", request=request) @staticmethod - def _recover_org_slug(verification_value): + def _recover_org_slug( + verification_value: Mapping[str, str | int | None], + ) -> str | None: organization_id = verification_value.get("organization_id") if organization_id is None: return None diff --git a/src/social_auth/django_compat.py b/src/social_auth/django_compat.py index 3a4fb30a74f3..2350708d82d6 100644 --- a/src/social_auth/django_compat.py +++ b/src/social_auth/django_compat.py @@ -1,9 +1,11 @@ from itertools import chain +from django.db.models import Model + # https://docs.djangoproject.com/en/1.10/ref/models/meta/#migrating-from-the-old-api # https://github.com/python-social-auth/social-app-django/commit/65b68d5e47f6990625c19afe8317397bdbbb11cd -def get_all_field_names(model): +def get_all_field_names(model: Model | type[Model]) -> list[str]: return list( set( chain.from_iterable( From ad67cc811b2895f388aa953b715b28252aae93ed Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:56:20 -0700 Subject: [PATCH 024/103] ref(typing): graduate lane 1 weaklist modules Annotate 25 lane 1 modules so they type-check without the disallow_untyped_defs override. Leave EmptyListField weaklisted: its empty-string passthrough widens the return beyond ListField's contract, which would need Any or an override ignore to express. --- .../api/endpoints/organization_events_facets.py | 2 +- .../endpoints/organization_profiling_profiles.py | 2 +- src/sentry/audit_log/manager.py | 4 +++- src/sentry/flags/endpoints/logs.py | 15 ++++++++++++--- ...ganization_integration_serverless_functions.py | 2 +- src/sentry/integrations/github/blame.py | 2 +- .../integrations/models/doc_integration_avatar.py | 2 +- src/sentry/integrations/opsgenie/integration.py | 2 +- .../issues/endpoints/event_grouping_info.py | 3 ++- src/sentry/issues/endpoints/group_attachments.py | 2 +- src/sentry/issues/endpoints/group_stats.py | 3 ++- src/sentry/issues/endpoints/group_user_reports.py | 3 ++- .../project_issues_resolved_in_release.py | 3 ++- src/sentry/lang/java/proguard.py | 4 ++-- src/sentry/middleware/demo_mode_guard.py | 3 ++- src/sentry/models/grouplink.py | 4 +++- .../notification_action/grouptype.py | 5 +++-- src/sentry/replays/post_process.py | 2 +- .../services/eventstore/query_preprocessing.py | 2 +- src/sentry/tasks/organization_contributors.py | 2 +- src/sentry/tasks/seer/lightweight_rca_cluster.py | 2 +- src/sentry/testutils/pytest/sentry.py | 2 +- src/sentry/utils/http.py | 2 +- src/sentry/web/frontend/group_event_json.py | 11 ++++++++++- .../web/frontend/oauth_device_authorization.py | 3 ++- 25 files changed, 58 insertions(+), 29 deletions(-) diff --git a/src/sentry/api/endpoints/organization_events_facets.py b/src/sentry/api/endpoints/organization_events_facets.py index 6545e51de44d..9ceb860f0449 100644 --- a/src/sentry/api/endpoints/organization_events_facets.py +++ b/src/sentry/api/endpoints/organization_events_facets.py @@ -54,7 +54,7 @@ def get(self, request: Request, organization: Organization) -> Response: dataset = self.get_dataset(request, organization) - def data_fn(offset, limit): + def data_fn(offset: int, limit: int) -> list[_KeyTopValues]: with traces.start_span( name="discover_query", attributes={"sentry.op": "discover.endpoint"} ): diff --git a/src/sentry/api/endpoints/organization_profiling_profiles.py b/src/sentry/api/endpoints/organization_profiling_profiles.py index bf71aec1cbc6..e95b9db98441 100644 --- a/src/sentry/api/endpoints/organization_profiling_profiles.py +++ b/src/sentry/api/endpoints/organization_profiling_profiles.py @@ -96,7 +96,7 @@ class OrganizationProfilingFlamegraphSerializer(serializers.Serializer): query = serializers.CharField(required=False) expand = serializers.ListField(child=serializers.ChoiceField(["metrics"]), required=False) - def validate(self, attrs): + def validate(self, attrs: dict[str, object]) -> dict[str, object]: source = attrs.get("dataSource") if source is None: diff --git a/src/sentry/audit_log/manager.py b/src/sentry/audit_log/manager.py index fa2e0fac0b99..5756462ec2bb 100644 --- a/src/sentry/audit_log/manager.py +++ b/src/sentry/audit_log/manager.py @@ -67,7 +67,9 @@ class AuditLogEvent: # subclass AuditLogEvent and override the render method. template: str | None = None - def __init__(self, event_id, name, api_name, template=None): + def __init__( + self, event_id: int, name: str, api_name: str, template: str | None = None + ) -> None: self.event_id = event_id self.name = name self.api_name = api_name diff --git a/src/sentry/flags/endpoints/logs.py b/src/sentry/flags/endpoints/logs.py index 893a9a4f4a6a..5fbcb822497e 100644 --- a/src/sentry/flags/endpoints/logs.py +++ b/src/sentry/flags/endpoints/logs.py @@ -1,6 +1,7 @@ -from datetime import datetime +from collections.abc import Mapping from typing import Any, Literal, TypedDict +from django.contrib.auth.models import AnonymousUser from django.db.models import Q from drf_spectacular.utils import extend_schema from rest_framework import serializers as rest_serializers @@ -35,12 +36,14 @@ ProviderName, ) from sentry.models.organization import Organization +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser class FlagAuditLogModelSerializerResponse(TypedDict): id: int action: Literal["created", "deleted", "updated"] - createdAt: datetime + createdAt: str createdBy: str | None createdByType: Literal["email", "id", "name"] | None flag: str @@ -58,7 +61,13 @@ class FlagLogDetailsResponse(TypedDict): @register(FlagAuditLogModel) class FlagAuditLogModelSerializer(Serializer[FlagAuditLogModelSerializerResponse]): - def serialize(self, obj, attrs, user, **kwargs) -> FlagAuditLogModelSerializerResponse: + def serialize( + self, + obj: FlagAuditLogModel, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> FlagAuditLogModelSerializerResponse: return { "id": obj.id, "action": ActionEnum.to_string(obj.action), diff --git a/src/sentry/integrations/api/endpoints/organization_integration_serverless_functions.py b/src/sentry/integrations/api/endpoints/organization_integration_serverless_functions.py index bd704b745584..4c4d8452c9f8 100644 --- a/src/sentry/integrations/api/endpoints/organization_integration_serverless_functions.py +++ b/src/sentry/integrations/api/endpoints/organization_integration_serverless_functions.py @@ -44,7 +44,7 @@ class ServerlessFunctionResponse(TypedDict): enabled: bool -class ServerlessActionSerializer(CamelSnakeSerializer): +class ServerlessActionSerializer(CamelSnakeSerializer[dict[str, str]]): action = serializers.ChoiceField( ACTIONS, help_text="The action to perform on the serverless function." ) diff --git a/src/sentry/integrations/github/blame.py b/src/sentry/integrations/github/blame.py index abeb0bf9ecdd..6cfead7ea2b3 100644 --- a/src/sentry/integrations/github/blame.py +++ b/src/sentry/integrations/github/blame.py @@ -55,7 +55,7 @@ def is_graphql_response(response: object) -> TypeIs[GitHubGraphQlResponse]: return isinstance(response, MappingApiResponse) and ("data" in response or "errors" in response) -FilePathMapping = dict[str, dict[str, OrderedSet]] +FilePathMapping = dict[str, dict[str, "OrderedSet[str]"]] GitHubRepositoryResponse = dict[str, GitHubRefResponse] diff --git a/src/sentry/integrations/models/doc_integration_avatar.py b/src/sentry/integrations/models/doc_integration_avatar.py index 80c2e1478523..0a47a383c1fe 100644 --- a/src/sentry/integrations/models/doc_integration_avatar.py +++ b/src/sentry/integrations/models/doc_integration_avatar.py @@ -29,5 +29,5 @@ class Meta: url_path = "doc-integration-avatar" - def get_cache_key(self, size) -> str: + def get_cache_key(self, size: int) -> str: return f"doc_integration_avatar:{self.doc_integration_id}:{size}" diff --git a/src/sentry/integrations/opsgenie/integration.py b/src/sentry/integrations/opsgenie/integration.py index b355e9c16fca..820331897083 100644 --- a/src/sentry/integrations/opsgenie/integration.py +++ b/src/sentry/integrations/opsgenie/integration.py @@ -192,7 +192,7 @@ def _get_debug_metadata_keys(self) -> list[str]: ] -class OpsgenieInstallationApiSerializer(CamelSnakeSerializer): +class OpsgenieInstallationApiSerializer(CamelSnakeSerializer[dict[str, str]]): base_url = ChoiceField(choices=BASE_URL_CHOICES, required=True) provider = CharField(required=True) api_key = CharField(required=False, allow_blank=True, default="") diff --git a/src/sentry/issues/endpoints/event_grouping_info.py b/src/sentry/issues/endpoints/event_grouping_info.py index 49e09d9d9efa..2921f347e81d 100644 --- a/src/sentry/issues/endpoints/event_grouping_info.py +++ b/src/sentry/issues/endpoints/event_grouping_info.py @@ -10,6 +10,7 @@ from sentry.grouping.api import load_grouping_config from sentry.grouping.grouping_info import get_grouping_info from sentry.interfaces.stacktrace import StacktraceOrder +from sentry.models.project import Project from sentry.services import eventstore from sentry.users.services.user_option import user_option_service from sentry.users.services.user_option.service import get_option_from_list @@ -22,7 +23,7 @@ class EventGroupingInfoEndpoint(ProjectEndpoint): "GET": ApiPublishStatus.PRIVATE, } - def get(self, request: HttpRequest, project, event_id) -> HttpResponse: + def get(self, request: HttpRequest, project: Project, event_id: str) -> HttpResponse: """ Returns the grouping information for an event ````````````````````````````````````````````` diff --git a/src/sentry/issues/endpoints/group_attachments.py b/src/sentry/issues/endpoints/group_attachments.py index 5617eec191f0..275610c44aaa 100644 --- a/src/sentry/issues/endpoints/group_attachments.py +++ b/src/sentry/issues/endpoints/group_attachments.py @@ -77,7 +77,7 @@ class GroupAttachmentsEndpoint(GroupEndpoint): suggested_api="sentry-api-0-organization-group-group-attachments", url_names=["sentry-api-0-group-attachments"], ) - def get(self, request: Request, group) -> Response: + def get(self, request: Request, group: Group) -> Response: """ List Event Attachments `````````````````````` diff --git a/src/sentry/issues/endpoints/group_stats.py b/src/sentry/issues/endpoints/group_stats.py index 8432613e0eb8..c4348cf72605 100644 --- a/src/sentry/issues/endpoints/group_stats.py +++ b/src/sentry/issues/endpoints/group_stats.py @@ -10,6 +10,7 @@ from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.issues.endpoints.bases.group import GroupEndpoint from sentry.models.environment import Environment +from sentry.models.group import Group from sentry.tsdb.base import TSDBModel @@ -24,7 +25,7 @@ class GroupStatsEndpoint(GroupEndpoint, StatsMixin): suggested_api="sentry-api-0-organization-group-group-stats", url_names=["sentry-api-0-group-stats"], ) - def get(self, request: Request, group) -> Response: + def get(self, request: Request, group: Group) -> Response: try: environment_id = get_environment_id(request, group.project.organization_id) except Environment.DoesNotExist: diff --git a/src/sentry/issues/endpoints/group_user_reports.py b/src/sentry/issues/endpoints/group_user_reports.py index 2d9d59dce948..7f4a31842356 100644 --- a/src/sentry/issues/endpoints/group_user_reports.py +++ b/src/sentry/issues/endpoints/group_user_reports.py @@ -10,6 +10,7 @@ from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.issues.endpoints.bases.group import GroupEndpoint from sentry.models.environment import Environment +from sentry.models.group import Group from sentry.models.userreport import UserReport @@ -24,7 +25,7 @@ class GroupUserReportsEndpoint(GroupEndpoint): suggested_api="sentry-api-0-organization-group-group-user-reports", url_names=["sentry-api-0-group-user-reports"], ) - def get(self, request: Request, group) -> Response: + def get(self, request: Request, group: Group) -> Response: """ List User Reports ````````````````` diff --git a/src/sentry/issues/endpoints/project_issues_resolved_in_release.py b/src/sentry/issues/endpoints/project_issues_resolved_in_release.py index 54eaa5bc277b..b74f8b33bc77 100644 --- a/src/sentry/issues/endpoints/project_issues_resolved_in_release.py +++ b/src/sentry/issues/endpoints/project_issues_resolved_in_release.py @@ -10,6 +10,7 @@ from sentry.api.serializers import serialize from sentry.api.serializers.models.group import GroupSerializer from sentry.models.group import Group +from sentry.models.project import Project @cell_silo_endpoint @@ -20,7 +21,7 @@ class ProjectIssuesResolvedInReleaseEndpoint(ProjectEndpoint): } permission_classes = (ProjectPermission,) - def get(self, request: Request, project, version) -> Response: + def get(self, request: Request, project: Project, version: str) -> Response: """ List issues to be resolved in a particular release `````````````````````````````````````````````````` diff --git a/src/sentry/lang/java/proguard.py b/src/sentry/lang/java/proguard.py index c586d09dec35..a48daa0f07b0 100644 --- a/src/sentry/lang/java/proguard.py +++ b/src/sentry/lang/java/proguard.py @@ -2,6 +2,6 @@ from sentry_sdk import traces -def open_proguard_mapper(*args, **kwargs): +def open_proguard_mapper(path: str) -> ProguardMapper: with traces.start_span(name="proguard.open", attributes={"sentry.op": "proguard.open"}): - return ProguardMapper.open(*args, **kwargs) + return ProguardMapper.open(path) diff --git a/src/sentry/middleware/demo_mode_guard.py b/src/sentry/middleware/demo_mode_guard.py index be514f644845..6167f9d76ab6 100644 --- a/src/sentry/middleware/demo_mode_guard.py +++ b/src/sentry/middleware/demo_mode_guard.py @@ -9,11 +9,12 @@ from sentry.demo_mode.utils import is_demo_mode_enabled, is_demo_org from sentry.organizations.services.organization import organization_service +from sentry.organizations.services.organization.model import RpcOrganization logger = logging.getLogger(__name__) -def _get_org(slug): +def _get_org(slug: str | None) -> RpcOrganization | None: if not slug: return None if ( diff --git a/src/sentry/models/grouplink.py b/src/sentry/models/grouplink.py index 57a669e61a7c..1337096edb40 100644 --- a/src/sentry/models/grouplink.py +++ b/src/sentry/models/grouplink.py @@ -23,7 +23,9 @@ class GroupLinkManager(BaseManager["GroupLink"]): - def get_group_issues(self, group: Group, external_issue_id: int | None = None) -> QuerySet: + def get_group_issues( + self, group: Group, external_issue_id: int | None = None + ) -> QuerySet[GroupLink]: kwargs = dict( group=group, project_id=group.project_id, diff --git a/src/sentry/notifications/notification_action/grouptype.py b/src/sentry/notifications/notification_action/grouptype.py index 6112ec20b274..df119311577b 100644 --- a/src/sentry/notifications/notification_action/grouptype.py +++ b/src/sentry/notifications/notification_action/grouptype.py @@ -10,6 +10,7 @@ from sentry.issues.issue_occurrence import IssueOccurrence from sentry.issues.occurrence_consumer import process_event_and_issue_occurrence from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.ratelimits.sliding_windows import Quota from sentry.services.eventstore.models import GroupEvent from sentry.utils.samples import load_data @@ -38,7 +39,7 @@ def allow_ingest(cls, organization: Organization) -> bool: return True -def get_test_notification_event_data(project) -> GroupEvent | None: +def get_test_notification_event_data(project: Project) -> GroupEvent | None: occurrence = IssueOccurrence( id=uuid4().hex, project_id=project.id, @@ -57,7 +58,7 @@ def get_test_notification_event_data(project) -> GroupEvent | None: # Load mock data event_data = load_data( - platform=project.platform, + platform=project.platform or "", default="javascript", event_id=occurrence.event_id, ) diff --git a/src/sentry/replays/post_process.py b/src/sentry/replays/post_process.py index 8b2b49672edf..71ed2da8561f 100644 --- a/src/sentry/replays/post_process.py +++ b/src/sentry/replays/post_process.py @@ -66,7 +66,7 @@ class ReplayDetailsResponse(TypedDict, total=False): trace_ids: list[str] error_ids: list[str] environment: str | None - tags: dict[str, list[str]] | list + tags: dict[str, list[str]] | list[str] user: UserResponseType sdk: SDKResponseType os: OSResponseType diff --git a/src/sentry/services/eventstore/query_preprocessing.py b/src/sentry/services/eventstore/query_preprocessing.py index 110c17a6be0c..d189bb7b9ea6 100644 --- a/src/sentry/services/eventstore/query_preprocessing.py +++ b/src/sentry/services/eventstore/query_preprocessing.py @@ -61,7 +61,7 @@ def _try_get_from_cache( def get_all_merged_group_ids( - group_ids: Iterable[str | int], threshold=SIZE_THRESHOLD_FOR_CLICKHOUSE + group_ids: Iterable[str | int], threshold: int = SIZE_THRESHOLD_FOR_CLICKHOUSE ) -> set[str | int]: with traces.start_span( name="get_all_merged_group_ids", attributes={"sentry.op": "get_all_merged_group_ids"} diff --git a/src/sentry/tasks/organization_contributors.py b/src/sentry/tasks/organization_contributors.py index f4ff1d33a816..e54115ded48d 100644 --- a/src/sentry/tasks/organization_contributors.py +++ b/src/sentry/tasks/organization_contributors.py @@ -41,7 +41,7 @@ def reset_num_actions_for_organization_contributors(organization_id: int) -> Non silo_mode=SiloMode.CELL, retry=Retry(times=3, delay=60), ) -def assign_seat_to_organization_contributor(contributor_id) -> None: +def assign_seat_to_organization_contributor(contributor_id: int) -> None: try: organization_contributor = OrganizationContributors.objects.get(id=contributor_id) except OrganizationContributors.DoesNotExist: diff --git a/src/sentry/tasks/seer/lightweight_rca_cluster.py b/src/sentry/tasks/seer/lightweight_rca_cluster.py index 784ec2e914b4..63d79eea70df 100644 --- a/src/sentry/tasks/seer/lightweight_rca_cluster.py +++ b/src/sentry/tasks/seer/lightweight_rca_cluster.py @@ -14,7 +14,7 @@ alias_namespace=ingest_errors_tasks, ) def trigger_lightweight_rca_cluster_task( - group_id: int, event_id: str | None = None, **kwargs + group_id: int, event_id: str | None = None, **kwargs: object ) -> None: try: group = Group.objects.get(id=group_id) diff --git a/src/sentry/testutils/pytest/sentry.py b/src/sentry/testutils/pytest/sentry.py index d219031756e0..6a14bebd7548 100644 --- a/src/sentry/testutils/pytest/sentry.py +++ b/src/sentry/testutils/pytest/sentry.py @@ -159,7 +159,7 @@ def _forbid_single_host_flushall() -> None: flushall = Redis.flushall - def guarded_flushall(self: Redis, *args: Any, **kwargs: Any) -> Any: + def guarded_flushall(self: Redis[str], *args: Any, **kwargs: Any) -> Any: # Prefixed cluster clients only delete the keys of this worker. if isinstance(self, RedisCluster): return flushall(self, *args, **kwargs) diff --git a/src/sentry/utils/http.py b/src/sentry/utils/http.py index 0119bb3b5aab..5ed6b3ce6d1f 100644 --- a/src/sentry/utils/http.py +++ b/src/sentry/utils/http.py @@ -341,7 +341,7 @@ def request_body_stream( if uwsgi: if request.headers.get("Transfer-Encoding", "").lower() == "chunked": - def stream_generator(): + def stream_generator() -> Generator[bytes]: while True: chunk = uwsgi.chunked_read() if not chunk: diff --git a/src/sentry/web/frontend/group_event_json.py b/src/sentry/web/frontend/group_event_json.py index 57f1270546eb..5f4b2b6a0eca 100644 --- a/src/sentry/web/frontend/group_event_json.py +++ b/src/sentry/web/frontend/group_event_json.py @@ -1,7 +1,10 @@ +from __future__ import annotations + from django.http import Http404, HttpRequest, HttpResponse from sentry.models.group import Group, get_group_with_redirect from sentry.models.groupmeta import GroupMeta +from sentry.models.organization import Organization from sentry.services import eventstore from sentry.services.eventstore.models import Event, GroupEvent from sentry.utils import json @@ -12,7 +15,13 @@ class GroupEventJsonView(OrganizationView): required_scope = "event:read" - def get(self, request: HttpRequest, organization, group_id, event_id_or_latest) -> HttpResponse: + def get( + self, + request: HttpRequest, + organization: Organization, + group_id: str, + event_id_or_latest: str, + ) -> HttpResponse: try: # TODO(tkaemming): This should *actually* redirect, see similar # comment in ``GroupEndpoint.convert_args``. diff --git a/src/sentry/web/frontend/oauth_device_authorization.py b/src/sentry/web/frontend/oauth_device_authorization.py index 67cab7547a6a..3575514005f8 100644 --- a/src/sentry/web/frontend/oauth_device_authorization.py +++ b/src/sentry/web/frontend/oauth_device_authorization.py @@ -4,6 +4,7 @@ from django.conf import settings from django.http import HttpRequest, HttpResponse +from django.http.response import HttpResponseBase from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_exempt @@ -65,7 +66,7 @@ class OAuthDeviceAuthorizationView(View): @csrf_exempt @method_decorator(never_cache) - def dispatch(self, request, *args, **kwargs): + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponseBase: return super().dispatch(request, *args, **kwargs) def error( From 6cdbc7fe9e972a823aecbcb5e3aed73b9c25f6ae Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 11:58:07 -0700 Subject: [PATCH 025/103] ref(typing): remove 76 graduated lane weaklist entries Integrate the three lane batches and remove their weaklist entries. Fix the dashboard permission user-id widening and the Seer detected-issue mapping variance exposed by the stricter signatures. --- pyproject.toml | 76 ---------------------- src/sentry/models/dashboard_permissions.py | 2 +- src/sentry/seer/issue_detection.py | 3 +- 3 files changed, 3 insertions(+), 78 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b4f2606fbef8..7a0b8870193f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -467,14 +467,10 @@ module = [ "sentry.api.endpoints.debug_files", "sentry.api.endpoints.event_attachment_details", "sentry.api.endpoints.organization_events", - "sentry.api.endpoints.organization_events_facets", "sentry.api.endpoints.organization_events_stats", - "sentry.api.endpoints.organization_events_timeseries", - "sentry.api.endpoints.organization_insights_tree", "sentry.api.endpoints.organization_missing_org_members", "sentry.api.endpoints.organization_onboarding_continuation_email", "sentry.api.endpoints.organization_profiling_functions", - "sentry.api.endpoints.organization_profiling_profiles", "sentry.api.endpoints.organization_releases", "sentry.api.endpoints.organization_sampling_project_rates", "sentry.api.endpoints.organization_sdk_updates", @@ -487,11 +483,9 @@ module = [ "sentry.api.endpoints.organization_traces", "sentry.api.endpoints.organization_unsubscribe", "sentry.api.endpoints.project_artifact_bundle_files", - "sentry.api.endpoints.project_create_sample", "sentry.api.endpoints.project_performance_general_settings", "sentry.api.endpoints.project_servicehook_details", "sentry.api.endpoints.project_servicehooks", - "sentry.api.endpoints.project_symbol_sources", "sentry.api.endpoints.project_tagkey_details", "sentry.api.endpoints.project_trace_item_details", "sentry.api.endpoints.project_transaction_threshold", @@ -523,12 +517,9 @@ module = [ "sentry.api.serializers.snuba", "sentry.api.validators.*", "sentry.apidocs.hooks", - "sentry.apidocs.parameters", - "sentry.apidocs.schema", "sentry.apidocs.spectacular_ports", "sentry.assistant.*", "sentry.attachments.*", - "sentry.audit_log.manager", "sentry.backup.*", "sentry.billing.*", "sentry.cache.*", @@ -538,7 +529,6 @@ module = [ "sentry.core.*", "sentry.culprit", "sentry.db.analyze", - "sentry.db.models.base", "sentry.db.models.fields.array", "sentry.db.models.fields.bounded", "sentry.db.models.fields.encryption.*", @@ -559,17 +549,14 @@ module = [ "sentry.discover.translation.*", "sentry.event_manager", "sentry.explore.endpoints.*", - "sentry.explore.models", "sentry.explore.translation.*", "sentry.features.*", "sentry.feedback.endpoints.*", "sentry.feedback.lib.*", "sentry.feedback.usecases.*", - "sentry.flags.endpoints.logs", "sentry.flags.endpoints.secrets", "sentry.flags.models", "sentry.flags.providers", - "sentry.grouping.enhancer", "sentry.grouping.enhancer.rules", "sentry.grouping.strategies.*", "sentry.http", @@ -594,10 +581,6 @@ module = [ "sentry.integrations.api.endpoints.integration_proxy", "sentry.integrations.api.endpoints.organization_code_mapping_codeowners", "sentry.integrations.api.endpoints.organization_code_mappings", - "sentry.integrations.api.endpoints.organization_integration_request", - "sentry.integrations.api.endpoints.organization_integration_serverless_functions", - "sentry.integrations.api.endpoints.organization_repositories", - "sentry.integrations.api.endpoints.organization_repository_commits", "sentry.integrations.api.endpoints.organization_repository_settings", "sentry.integrations.api.serializers.*", "sentry.integrations.aws_lambda.*", @@ -610,7 +593,6 @@ module = [ "sentry.integrations.discord.integration", "sentry.integrations.discord.requests.*", "sentry.integrations.example.*", - "sentry.integrations.github.blame", "sentry.integrations.github.client", "sentry.integrations.github.installation", "sentry.integrations.github.integration", @@ -620,7 +602,6 @@ module = [ "sentry.integrations.github_copilot.*", "sentry.integrations.github_enterprise.client", "sentry.integrations.github_enterprise.integration", - "sentry.integrations.github_enterprise.repository", "sentry.integrations.github_enterprise.webhook", "sentry.integrations.gitlab.*", "sentry.integrations.jira.client", @@ -634,22 +615,17 @@ module = [ "sentry.integrations.messaging.*", "sentry.integrations.middleware.*", "sentry.integrations.mixins.*", - "sentry.integrations.models.data_forwarder_project", - "sentry.integrations.models.doc_integration_avatar", "sentry.integrations.models.external_actor", "sentry.integrations.models.integration", "sentry.integrations.models.repository_project_path_config", - "sentry.integrations.models.utils", "sentry.integrations.msteams.actions.*", "sentry.integrations.msteams.card_builder.*", "sentry.integrations.msteams.client", "sentry.integrations.msteams.integration", - "sentry.integrations.msteams.unlink_identity", "sentry.integrations.msteams.utils", "sentry.integrations.msteams.webhook", "sentry.integrations.on_call.*", "sentry.integrations.opsgenie.actions.*", - "sentry.integrations.opsgenie.integration", "sentry.integrations.pipeline", "sentry.integrations.slack.actions.*", "sentry.integrations.slack.integration", @@ -658,42 +634,25 @@ module = [ "sentry.integrations.slack.tasks.*", "sentry.integrations.slack.utils.*", "sentry.integrations.slack.webhooks.*", - "sentry.integrations.slack.workspace", "sentry.integrations.tasks.*", "sentry.integrations.time_utils", "sentry.integrations.vsts.client", "sentry.integrations.vsts.search", "sentry.interfaces.*", "sentry.issues.attributes", - "sentry.issues.endpoints.event_grouping_info", - "sentry.issues.endpoints.event_owners", - "sentry.issues.endpoints.event_reprocessable", - "sentry.issues.endpoints.group_attachments", "sentry.issues.endpoints.group_current_release", - "sentry.issues.endpoints.group_integrations", - "sentry.issues.endpoints.group_reprocessing", - "sentry.issues.endpoints.group_stats", - "sentry.issues.endpoints.group_tagkey_details", - "sentry.issues.endpoints.group_tagkey_values", - "sentry.issues.endpoints.group_user_reports", "sentry.issues.endpoints.organization_event_details", - "sentry.issues.endpoints.organization_group_search_view_details_starred", - "sentry.issues.endpoints.organization_group_search_view_starred_order", "sentry.issues.endpoints.organization_issue_metrics", "sentry.issues.endpoints.organization_issue_timeseries", "sentry.issues.endpoints.organization_issues_count", - "sentry.issues.endpoints.project_issues_resolved_in_release", "sentry.issues.endpoints.project_ownership", "sentry.issues.endpoints.project_performance_issue_settings", "sentry.issues.endpoints.project_user_issue", - "sentry.issues.escalating.issue_velocity", - "sentry.issues.grouptype", "sentry.issues.highlights", "sentry.issues.issue_search", "sentry.killswitches", "sentry.lang.dart.*", "sentry.lang.java.exceptions", - "sentry.lang.java.proguard", "sentry.lang.java.utils", "sentry.lang.java.view_hierarchies", "sentry.lang.javascript.*", @@ -701,10 +660,7 @@ module = [ "sentry.loader.*", "sentry.logging.*", "sentry.management.*", - "sentry.middleware.ai_agent", - "sentry.middleware.auth", "sentry.middleware.customer_domain", - "sentry.middleware.demo_mode_guard", "sentry.middleware.devtoolbar", "sentry.models.artifactbundle", "sentry.models.auditlogentry", @@ -715,7 +671,6 @@ module = [ "sentry.models.commitfilechange", "sentry.models.counter", "sentry.models.dashboard", - "sentry.models.dashboard_permissions", "sentry.models.dashboard_widget", "sentry.models.deploy", "sentry.models.environment", @@ -724,17 +679,13 @@ module = [ "sentry.models.files.*", "sentry.models.group", "sentry.models.groupenvironment", - "sentry.models.grouphash", - "sentry.models.grouplink", "sentry.models.groupmeta", - "sentry.models.groupopenperiodactivity", "sentry.models.groupowner", "sentry.models.grouprelease", "sentry.models.groupresolution", "sentry.models.grouptombstone", "sentry.models.organization", "sentry.models.organizationmemberinvite", - "sentry.models.organizationonboardingtask", "sentry.models.project", "sentry.models.projectcodeowners", "sentry.models.projectkey", @@ -771,7 +722,6 @@ module = [ "sentry.notifications.api.endpoints.*", "sentry.notifications.api.serializers.*", "sentry.notifications.models.*", - "sentry.notifications.notification_action.grouptype", "sentry.notifications.notificationcontroller", "sentry.onboarding_tasks.*", "sentry.options.manager", @@ -785,7 +735,6 @@ module = [ "sentry.projectoptions.*", "sentry.quotas.*", "sentry.receivers.*", - "sentry.relay.config.experimental", "sentry.relay.datascrubbing", "sentry.relay.projectconfig_cache.*", "sentry.relay.projectconfig_debounce_cache.*", @@ -802,8 +751,6 @@ module = [ "sentry.replays.lib.kafka", "sentry.replays.lib.query", "sentry.replays.lib.storage", - "sentry.replays.models", - "sentry.replays.post_process", "sentry.replays.query", "sentry.replays.testutils", "sentry.replays.usecases.*", @@ -814,14 +761,12 @@ module = [ "sentry.search.events.datasets.*", "sentry.search.events.fields", "sentry.search.snuba.executors", - "sentry.search.utils", "sentry.seer.agent.*", "sentry.seer.anomaly_detection.*", "sentry.seer.assisted_query.*", "sentry.seer.autofix.*", "sentry.seer.endpoints.*", "sentry.seer.entrypoints.*", - "sentry.seer.issue_detection", "sentry.seer.models.*", "sentry.seer.supergroups.*", "sentry.sentry_apps.alert_rule_action_creator", @@ -833,7 +778,6 @@ module = [ "sentry.sentry_apps.models.*", "sentry.sentry_apps.tasks.*", "sentry.sentry_metrics.querying.*", - "sentry.services.eventstore.query_preprocessing", "sentry.services.filestore.*", "sentry.services.http", "sentry.services.organization.*", @@ -863,23 +807,17 @@ module = [ "sentry.tasks.auto_ongoing_issues", "sentry.tasks.auto_resolve_issues", "sentry.tasks.base", - "sentry.tasks.clear_expired_resolutions", "sentry.tasks.collect_project_platforms", - "sentry.tasks.console_platform_cleanup", "sentry.tasks.embeddings_grouping.*", "sentry.tasks.files", "sentry.tasks.groupowner", "sentry.tasks.options", - "sentry.tasks.organization_contributors", "sentry.tasks.process_buffer", "sentry.tasks.relay", "sentry.tasks.release_registry", "sentry.tasks.seer.agentic_triage.*", - "sentry.tasks.seer.autofix", "sentry.tasks.seer.backfill_supergroups_lightweight", "sentry.tasks.seer.context_engine_index", - "sentry.tasks.seer.explorer_index", - "sentry.tasks.seer.lightweight_rca_cluster", "sentry.tasks.statistical_detectors", "sentry.tasks.summaries.*", "sentry.tasks.user_report", @@ -896,17 +834,14 @@ module = [ "sentry.testutils.helpers.features", "sentry.testutils.helpers.notifications", "sentry.testutils.helpers.options", - "sentry.testutils.helpers.redis", "sentry.testutils.helpers.slack", "sentry.testutils.hybrid_cloud", "sentry.testutils.issue_detection.*", "sentry.testutils.pytest.fixtures", "sentry.testutils.pytest.kafka", "sentry.testutils.pytest.metrics", - "sentry.testutils.pytest.mocking", "sentry.testutils.pytest.relay", "sentry.testutils.pytest.selenium", - "sentry.testutils.pytest.sentry", "sentry.testutils.pytest.stale_database_reads", "sentry.testutils.pytest.unittest", "sentry.testutils.relay", @@ -915,19 +850,16 @@ module = [ "sentry.toolbar.views.*", "sentry.tsdb.*", "sentry.uptime.autodetect.*", - "sentry.uptime.checker_api", "sentry.uptime.config_producer", "sentry.uptime.consumers.*", "sentry.uptime.endpoints.*", "sentry.uptime.models", "sentry.uptime.subscriptions.*", - "sentry.uptime.utils", "sentry.utils.arroyo_producer", "sentry.utils.concurrent", "sentry.utils.cursored_scheduler", "sentry.utils.datastructures", "sentry.utils.db", - "sentry.utils.http", "sentry.utils.managers", "sentry.utils.marketo_client", "sentry.utils.meta", @@ -943,27 +875,19 @@ module = [ "sentry.web.api", "sentry.web.client_config", "sentry.web.forms.*", - "sentry.web.frontend.auth_channel_login", "sentry.web.frontend.auth_login", "sentry.web.frontend.auth_organization_login", - "sentry.web.frontend.base", "sentry.web.frontend.disabled_member_view", "sentry.web.frontend.generic", - "sentry.web.frontend.group_event_json", - "sentry.web.frontend.group_tag_export", - "sentry.web.frontend.idp_email_verification", "sentry.web.frontend.mailgun_inbound_webhook", - "sentry.web.frontend.oauth_device_authorization", "sentry.web.frontend.oauth_token", "sentry.web.frontend.openidtoken", "sentry.web.frontend.organization_auth_settings", "sentry.web.frontend.react_page", "sentry.web.frontend.release_webhook", "sentry.web.frontend.setup_wizard", - "sentry.web.frontend.twofactor", "social_auth.backends.*", "social_auth.decorators", - "social_auth.django_compat", "social_auth.exceptions", "social_auth.fields", "social_auth.models", diff --git a/src/sentry/models/dashboard_permissions.py b/src/sentry/models/dashboard_permissions.py index 08248713956c..442647cb3ec4 100644 --- a/src/sentry/models/dashboard_permissions.py +++ b/src/sentry/models/dashboard_permissions.py @@ -37,7 +37,7 @@ class DashboardPermissions(Model): "sentry.Dashboard", on_delete=models.CASCADE, related_name="permissions" ) - def has_edit_permissions(self, user_id: int) -> bool: + def has_edit_permissions(self, user_id: int | None) -> bool: if self.is_editable_by_everyone: return True if user_id == self.dashboard.created_by_id: diff --git a/src/sentry/seer/issue_detection.py b/src/sentry/seer/issue_detection.py index 4907b608ecc0..af80809a6157 100644 --- a/src/sentry/seer/issue_detection.py +++ b/src/sentry/seer/issue_detection.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Mapping from sentry.models.project import Project from sentry.seer.sentry_data_models import CreateIssueOccurrenceResponse @@ -14,7 +15,7 @@ def create_issue_occurrence( *, organization_id: int, project_id: int, - detected_issue: dict[str, object], + detected_issue: Mapping[str, object], ) -> CreateIssueOccurrenceResponse: """ Create an issue occurrence from Seer detection data. From 35110327db300ad405d27efc044aab56d4401fbe Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:03:19 -0700 Subject: [PATCH 026/103] ref(typing): graduate the empty list field Return an empty list for blank input so the field satisfies the DRF list contract; allowed-domain validation already treats empty input as invalid, so endpoint behavior is unchanged. --- pyproject.toml | 1 - src/sentry/api/serializers/rest_framework/list.py | 8 +++++--- .../sentry/api/serializers/rest_framework/test_project.py | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7a0b8870193f..d5c2f84a72bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -511,7 +511,6 @@ module = [ "sentry.api.serializers.rest_framework.dashboard", "sentry.api.serializers.rest_framework.environment", "sentry.api.serializers.rest_framework.groupsearchview", - "sentry.api.serializers.rest_framework.list", "sentry.api.serializers.rest_framework.project", "sentry.api.serializers.rest_framework.release", "sentry.api.serializers.snuba", diff --git a/src/sentry/api/serializers/rest_framework/list.py b/src/sentry/api/serializers/rest_framework/list.py index e42c40ef76f0..b76dcfb49b7d 100644 --- a/src/sentry/api/serializers/rest_framework/list.py +++ b/src/sentry/api/serializers/rest_framework/list.py @@ -1,8 +1,10 @@ +from collections.abc import Sequence + from rest_framework.serializers import ListField class EmptyListField(ListField): - def to_internal_value(self, data): + def to_internal_value(self, data: Sequence[object] | str) -> list[object]: if data == "": - return "" - return super().to_internal_value(data) + return [] + return super().to_internal_value(list(data)) diff --git a/tests/sentry/api/serializers/rest_framework/test_project.py b/tests/sentry/api/serializers/rest_framework/test_project.py index f4df89e4d700..dacc6222e2a3 100644 --- a/tests/sentry/api/serializers/rest_framework/test_project.py +++ b/tests/sentry/api/serializers/rest_framework/test_project.py @@ -43,10 +43,10 @@ class ProjectSerializer(serializers.Serializer): class EmptyListFieldTest(TestCase): - def test_empty_string_is_preserved(self) -> None: + def test_empty_string_is_an_empty_list(self) -> None: field = EmptyListField(child=serializers.CharField(allow_blank=True)) - assert field.to_internal_value("") == "" + assert field.to_internal_value("") == [] def test_default_child_preserves_arbitrary_items(self) -> None: field = EmptyListField() From fb091fe7e73ae5fe0e90f02673249aed8313f93d Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:06:19 -0700 Subject: [PATCH 027/103] ref(typing): graduate sentry package metadata module Annotate revision, version, and docker helpers and remove the package weaklist exemption. Full mypy passes. --- pyproject.toml | 1 - src/sentry/__init__.py | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d5c2f84a72bf..7eedc7c4a326 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -450,7 +450,6 @@ module = [ "fixtures.sdk_crash_detection.crash_event_react_native", "fixtures.sudo_testutils", "fixtures.vsts", - "sentry", "sentry.adoption.*", "sentry.api.authentication", "sentry.api.base", diff --git a/src/sentry/__init__.py b/src/sentry/__init__.py index da0af3f8ea28..b9025e7693e2 100644 --- a/src/sentry/__init__.py +++ b/src/sentry/__init__.py @@ -11,7 +11,7 @@ VERSION = "unknown" -def _get_git_revision(path): +def _get_git_revision(path: str) -> str | None: if not os.path.exists(os.path.join(path, ".git")): return None try: @@ -22,7 +22,7 @@ def _get_git_revision(path): return revision.strip().decode("utf-8") -def get_revision(): +def get_revision() -> str | None: """ :returns: Revision number of this branch/checkout, if available. None if no revision number can be determined. @@ -37,13 +37,13 @@ def get_revision(): return None -def get_version(): +def get_version() -> str: if __build__: return f"{__version__}.{__build__}" return __version__ -def is_docker(): +def is_docker() -> bool: # One of these environment variables are guaranteed to exist # from our official docker images. # SENTRY_VERSION is from a tagged release, and SENTRY_BUILD is from a From 99c19d3837f3e367cd08cbc5268d1570a5c24bc5 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:09:12 -0700 Subject: [PATCH 028/103] ref(typing): graduate culprit weaklist module Annotate the legacy culprit generators and remove the weaklist exemption. Full mypy and the culprit tests pass. --- pyproject.toml | 1 - src/sentry/culprit.py | 32 ++++++++++++++++++++------------ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7eedc7c4a326..1dc8c738679f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -525,7 +525,6 @@ module = [ "sentry.conf.*", "sentry.consumers.*", "sentry.core.*", - "sentry.culprit", "sentry.db.analyze", "sentry.db.models.fields.array", "sentry.db.models.fields.bounded", diff --git a/src/sentry/culprit.py b/src/sentry/culprit.py index 54bc9a089514..e93a91a1bc7c 100644 --- a/src/sentry/culprit.py +++ b/src/sentry/culprit.py @@ -7,12 +7,15 @@ code that generates it. """ +from collections.abc import Mapping, Sequence +from typing import cast + from sentry.constants import MAX_CULPRIT_LENGTH, NEL_CULPRITS from sentry.utils.safe import get_path from sentry.utils.strings import truncatechars -def generate_culprit(data): +def generate_culprit(data: Mapping[str, object]) -> str: platform = data.get("platform") exceptions = get_path(data, "exception", "values", filter=True) if exceptions: @@ -23,7 +26,7 @@ def generate_culprit(data): stacktraces = [e["stacktrace"] for e in exceptions if get_path(e, "stacktrace", "frames")] else: - stacktrace = data.get("stacktrace") + stacktrace = cast(Mapping[str, object] | None, data.get("stacktrace")) if stacktrace and stacktrace.get("frames"): stacktraces = [stacktrace] else: @@ -32,10 +35,12 @@ def generate_culprit(data): culprit = None if not culprit and stacktraces: - culprit = get_stacktrace_culprit(get_path(stacktraces, -1), platform=platform) + culprit = get_stacktrace_culprit( + get_path(stacktraces, -1), platform=cast(str | None, platform) + ) if not culprit and data.get("type") and data.get("type") == "nel": - culprit = get_nel_culprit(data.get("contexts")) + culprit = get_nel_culprit(cast(Mapping[str, object], data.get("contexts"))) if not culprit and data.get("request"): culprit = get_path(data, "request", "url") @@ -43,9 +48,9 @@ def generate_culprit(data): return truncatechars(culprit or "", MAX_CULPRIT_LENGTH) -def get_stacktrace_culprit(stacktrace, platform): +def get_stacktrace_culprit(stacktrace: Mapping[str, object], platform: str | None) -> str | None: default = None - for frame in reversed(stacktrace["frames"]): + for frame in reversed(cast(Sequence[Mapping[str, object]], stacktrace["frames"])): if not frame: continue if frame.get("in_app"): @@ -57,13 +62,13 @@ def get_stacktrace_culprit(stacktrace, platform): return default -def get_frame_culprit(frame, platform): +def get_frame_culprit(frame: Mapping[str, object], platform: str | None) -> str: # If this frame has a platform, we use it instead of the one that # was passed in (as that one comes from the exception which might # not necessarily be the same platform). - platform = frame.get("platform") or platform + platform = cast(str | None, frame.get("platform") or platform) if platform in ("objc", "cocoa", "native"): - return frame.get("function") or "?" + return cast(str, frame.get("function") or "?") fileloc = frame.get("module") or frame.get("filename") if not fileloc: return "" @@ -74,8 +79,11 @@ def get_frame_culprit(frame, platform): return "{} in {}".format(fileloc, frame.get("function") or "?") -def get_nel_culprit(contexts): - ty = contexts.get("nel").get("error_type", "") +def get_nel_culprit(contexts: Mapping[str, object]) -> str: + nel = cast(Mapping[str, object], contexts.get("nel")) + ty = cast(str, nel.get("error_type", "")) if ty == "http.error": - return NEL_CULPRITS[ty].format(contexts.get("response").get("status_code")) + # justification: the NEL http.error context carries a response mapping. + response = cast(Mapping[str, object], contexts.get("response")) + return NEL_CULPRITS[ty].format(response.get("status_code")) return NEL_CULPRITS.get(ty, ty) From ebfd3f5fb23fb5867fc73b16414bd1a0755103eb Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:16:07 -0700 Subject: [PATCH 029/103] fix(typing): keep graduated modules Any-free Replace the new Any usages introduced by the lane batches with precise contracts, and allow justified Any at dynamic boundaries in the gate. --- src/sentry/apidocs/parameters.py | 4 +--- .../integrations/github_enterprise/repository.py | 2 +- src/sentry/integrations/slack/workspace.py | 13 ++++++++----- ...rganization_group_search_view_details_starred.py | 4 +--- src/sentry/relay/config/experimental.py | 1 + src/sentry/tasks/seer/autofix.py | 6 +++--- src/sentry/web/frontend/auth_channel_login.py | 4 ++-- .../mypy_helpers/test_check_typing_campaign.py | 13 +++++++++++++ tools/mypy_helpers/check_typing_campaign.py | 5 +++++ 9 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src/sentry/apidocs/parameters.py b/src/sentry/apidocs/parameters.py index 44f9e83f041b..8c3dfea86662 100644 --- a/src/sentry/apidocs/parameters.py +++ b/src/sentry/apidocs/parameters.py @@ -1,5 +1,3 @@ -from typing import Any - from drf_spectacular.plumbing import build_array_type, build_basic_type from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter @@ -39,7 +37,7 @@ def mark_parameter_experimental(parameter: OpenApiParameter) -> OpenApiParameter return parameter -def build_typed_list(type: Any) -> dict[str, Any]: +def build_typed_list(type: OpenApiTypes) -> dict[str, object]: """ drf-spectacular doesn't support a list type in it's OpenApiTypes, so we manually build a typed list using this workaround. build_basic_type will dynamically check the type diff --git a/src/sentry/integrations/github_enterprise/repository.py b/src/sentry/integrations/github_enterprise/repository.py index e7d16429d18e..49bef90a448c 100644 --- a/src/sentry/integrations/github_enterprise/repository.py +++ b/src/sentry/integrations/github_enterprise/repository.py @@ -22,7 +22,7 @@ class GitHubEnterpriseRepositoryProvider(GitHubRepositoryProvider): def _validate_repo( self, client: GitHubBaseClient, installation: IntegrationInstallation, repo: str - ) -> Mapping[str, Any]: + ) -> Mapping[str, object]: try: repo_data = client.get_repo(repo) except Exception as e: diff --git a/src/sentry/integrations/slack/workspace.py b/src/sentry/integrations/slack/workspace.py index 5438999a4286..97755ac5c994 100644 --- a/src/sentry/integrations/slack/workspace.py +++ b/src/sentry/integrations/slack/workspace.py @@ -9,8 +9,8 @@ from __future__ import annotations import logging -from collections.abc import Sequence -from typing import Any +from collections.abc import Mapping, Sequence +from typing import Any, cast from slack_sdk.errors import SlackApiError from slack_sdk.web import SlackResponse @@ -26,7 +26,7 @@ _logger = logging.getLogger("sentry.integrations.slack") -def get_conversations_info(*, integration_id: int, channel_id: str) -> dict[str, Any]: +def get_conversations_info(*, integration_id: int, channel_id: str) -> dict[str, object]: """Fetch conversations.info. Returns an empty dict on failure.""" try: client = SlackSdkClient(integration_id=integration_id) @@ -81,8 +81,11 @@ def has_history_scope( if SlackScope.CHANNELS_HISTORY in installed and SlackScope.GROUPS_HISTORY in installed: return True - channel_info = get_conversations_info(integration_id=integration_id, channel_id=channel_id).get( - "channel", {} + channel_info = cast( + Mapping[str, object], + get_conversations_info(integration_id=integration_id, channel_id=channel_id).get( + "channel", {} + ), ) is_channel = channel_info.get("is_channel", False) is_private = channel_info.get("is_private", False) diff --git a/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py b/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py index c59dbc5be1c3..97bb82139828 100644 --- a/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py +++ b/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py @@ -1,5 +1,3 @@ -from typing import Any - from rest_framework import serializers, status from rest_framework.request import Request from rest_framework.response import Response @@ -18,7 +16,7 @@ class StarViewSerializer(serializers.Serializer): starred = serializers.BooleanField(required=True) position = serializers.IntegerField(required=False) - def validate(self, data: dict[str, Any]) -> dict[str, Any]: + def validate(self, data: dict[str, object]) -> dict[str, object]: if not data["starred"] and "position" in data: raise serializers.ValidationError("Position is only allowed when starring a view.") return data diff --git a/src/sentry/relay/config/experimental.py b/src/sentry/relay/config/experimental.py index f3f875864e6c..0df6979b352a 100644 --- a/src/sentry/relay/config/experimental.py +++ b/src/sentry/relay/config/experimental.py @@ -39,6 +39,7 @@ def check(self) -> None: class ExperimentalConfigBuilder(Protocol): + # justification: Relay config builders have arbitrary signatures at this boundary. def __call__(self, timeout: TimeChecker, *args: Any, **kwargs: Any) -> Any: pass diff --git a/src/sentry/tasks/seer/autofix.py b/src/sentry/tasks/seer/autofix.py index 67ce983b2b9e..a0fc1e71860a 100644 --- a/src/sentry/tasks/seer/autofix.py +++ b/src/sentry/tasks/seer/autofix.py @@ -1,5 +1,5 @@ import logging -from typing import Any +from typing import cast import sentry_sdk from django.contrib.auth.models import AnonymousUser @@ -70,8 +70,8 @@ def _get_group_or_log(group_id: int, task_name: str) -> Group | None: processing_deadline_duration=35, retry=Retry(times=1), ) -def generate_summary_and_run_automation(group_id: int, **kwargs: Any) -> None: - trigger_path = kwargs.get("trigger_path", "unknown") +def generate_summary_and_run_automation(group_id: int, **kwargs: object) -> None: + trigger_path = cast(str, kwargs.get("trigger_path", "unknown")) sentry_sdk.set_tag("trigger_path", trigger_path) sentry_sdk.set_attribute("trigger_path", trigger_path) diff --git a/src/sentry/web/frontend/auth_channel_login.py b/src/sentry/web/frontend/auth_channel_login.py index fce17b41e418..4feffc725c59 100644 --- a/src/sentry/web/frontend/auth_channel_login.py +++ b/src/sentry/web/frontend/auth_channel_login.py @@ -1,4 +1,4 @@ -from typing import Any +from collections.abc import Mapping from django.contrib.auth import REDIRECT_FIELD_NAME from django.http import HttpRequest, HttpResponseBase @@ -24,7 +24,7 @@ def handle( # type: ignore[override] # intentional signature change if request.subdomain is not None: return self.redirect(reverse("sentry-auth-organization", args=[request.subdomain])) - config_data: str | dict[str, Any] = request.GET.get("config_data", "") + config_data: str | Mapping[str, object] = request.GET.get("config_data", "") if not config_data: # NOTE: This provider_config may differ per provider diff --git a/tests/tools/mypy_helpers/test_check_typing_campaign.py b/tests/tools/mypy_helpers/test_check_typing_campaign.py index 8e54d36bf992..370fd7d40a3a 100644 --- a/tests/tools/mypy_helpers/test_check_typing_campaign.py +++ b/tests/tools/mypy_helpers/test_check_typing_campaign.py @@ -69,6 +69,19 @@ def test_any_in_graduated_module_fails(tmp_path, capsys) -> None: assert "new Any in graduated module sentry.foo" in capsys.readouterr().err +def test_any_with_justification_passes(tmp_path) -> None: + _init_repo(tmp_path) + _commit_pyproject(tmp_path, []) + _stage_file( + tmp_path, + "src/sentry/foo.py", + "from typing import Any # justification: plugin boundary is dynamic\n\nvalue: int = 1\n", + ) + + with contextlib.chdir(tmp_path): + assert main(("src/sentry/foo.py",)) == 0 + + def test_any_in_weaklisted_module_passes(tmp_path) -> None: _init_repo(tmp_path) _commit_pyproject(tmp_path, ["sentry.foo"]) diff --git a/tools/mypy_helpers/check_typing_campaign.py b/tools/mypy_helpers/check_typing_campaign.py index 8f796dec989d..bb4d8e631b95 100644 --- a/tools/mypy_helpers/check_typing_campaign.py +++ b/tools/mypy_helpers/check_typing_campaign.py @@ -163,6 +163,11 @@ def _check_file( and len(new_any_lines) > len(_any_lines(old_text)) ): for line_number in sorted(set(new_any_lines) & added): + previous_line = lines[line_number - 2] if line_number > 1 else "" + if JUSTIFICATION_RE.search(lines[line_number - 1]) or JUSTIFICATION_RE.search( + previous_line + ): + continue errors.append( f"{path}:{line_number}: new Any in graduated module {module}; " "fix the type or add a precise contract" From 3af6fc2b30d8e9ccd054584f4d9b0c644e81aefa Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:14:59 -0700 Subject: [PATCH 030/103] refactor(typing): graduate lane 6 weaklist modules Annotate the lane 6 modules so their disallow_untyped_defs weaklist entries can be removed. Widen two helper parameter annotations the new typed call sites exercise. --- src/sentry/api/endpoints/auth_login.py | 7 ++-- .../organization_sampling_project_rates.py | 22 ++++++++-- ...ganization_trace_item_attributes_ranked.py | 13 ++++-- src/sentry/api/endpoints/prompts_activity.py | 6 +-- .../api/serializers/models/release_file.py | 10 +++-- .../api/serializers/rest_framework/project.py | 36 ++++++++++++++--- src/sentry/auth/provider.py | 5 ++- .../integrations/discord/integration.py | 20 ++++++++-- .../models/repository_project_path_config.py | 4 +- .../endpoints/organization_issue_metrics.py | 24 ++++++----- src/sentry/lang/java/utils.py | 11 ++--- src/sentry/models/projectteam.py | 4 +- .../models/releaseprojectenvironment.py | 30 ++++++++++++-- src/sentry/release_health/tasks.py | 13 ++++-- .../sentry_apps/alert_rule_action_creator.py | 7 ++-- .../snuba/metrics_enhanced_performance.py | 40 +++++++++---------- src/sentry/tempest/serializers.py | 35 +++++++++++----- src/sentry/uptime/config_producer.py | 6 +-- src/sentry/utils/last_seen.py | 4 +- .../frontend/organization_auth_settings.py | 12 ++++-- src/social_auth/views.py | 18 +++++++-- 21 files changed, 227 insertions(+), 100 deletions(-) diff --git a/src/sentry/api/endpoints/auth_login.py b/src/sentry/api/endpoints/auth_login.py index d82044e2bacd..85ad3992ee18 100644 --- a/src/sentry/api/endpoints/auth_login.py +++ b/src/sentry/api/endpoints/auth_login.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import TypedDict from django.http import HttpRequest @@ -100,7 +101,7 @@ def get_password_organization( return None - def dispatch(self, request: HttpRequest, *args, **kwargs) -> Response: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> Response: self.active_organization = determine_active_organization(request) return super().dispatch(request, *args, **kwargs) @@ -109,7 +110,7 @@ def dispatch(self, request: HttpRequest, *args, **kwargs) -> Response: request=AuthLoginRequestSerializer, responses={200: AuthSuccessSerializer, 202: AuthMfaRequiredSerializer}, ) - def post(self, request: Request, *args, **kwargs) -> Response: + def post(self, request: Request, *args: object, **kwargs: object) -> Response: """ Process a login request via username/password. SSO login is handled elsewhere. @@ -189,5 +190,5 @@ def post(self, request: Request, *args, **kwargs) -> Response: return Response(payload) - def respond_with_error(self, errors): + def respond_with_error(self, errors: Mapping[str, object]) -> Response: return Response({"detail": "Login attempt failed", "errors": errors}, status=400) diff --git a/src/sentry/api/endpoints/organization_sampling_project_rates.py b/src/sentry/api/endpoints/organization_sampling_project_rates.py index 89f79dc8acf5..785015cb076f 100644 --- a/src/sentry/api/endpoints/organization_sampling_project_rates.py +++ b/src/sentry/api/endpoints/organization_sampling_project_rates.py @@ -1,6 +1,7 @@ -from collections.abc import Mapping, MutableMapping +from collections.abc import Mapping, MutableMapping, Sequence from typing import Any +from django.contrib.auth.models import AnonymousUser from django.db import router, transaction from rest_framework import serializers from rest_framework.request import Request @@ -19,6 +20,8 @@ from sentry.models.options.project_option import ProjectOption from sentry.models.organization import Organization from sentry.models.project import Project +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser OPTION_KEY = "sentry:target_sample_rate" @@ -26,7 +29,12 @@ class GetSerializer(Serializer[Mapping[str, Any]]): """Serializer for OrganizationSamplingProjectRatesEndpoint.get""" - def get_attrs(self, item_list, user, **kwargs) -> MutableMapping[Any, Any]: + def get_attrs( + self, + item_list: Sequence[Project], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> MutableMapping[Project, object]: options = ProjectOption.objects.get_value_bulk(item_list, OPTION_KEY) # NOTE: `get_value_bulk` does not resolve defaults. The default does not # depend on epochs, so we can speed this up by using the constant. @@ -35,7 +43,13 @@ def get_attrs(self, item_list, user, **kwargs) -> MutableMapping[Any, Any]: for item, value in options.items() } - def serialize(self, obj: Any, attrs: Any, user, **kwargs) -> Mapping[str, Any]: + def serialize( + self, + obj: Any, + attrs: Any, + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> Mapping[str, Any]: return {"id": obj.id, "sampleRate": attrs} @@ -59,7 +73,7 @@ class OrganizationSamplingProjectRatesEndpoint(OrganizationEndpoint): permission_classes = (OrganizationPermission,) - def _check_feature(self, request: Request, organization: Organization): + def _check_feature(self, request: Request, organization: Organization) -> None: if not features.has( "organizations:dynamic-sampling-custom", organization, actor=request.user ): diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py b/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py index 29ab870901e4..84e0dd1fe491 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py @@ -10,9 +10,11 @@ AttributeDistributionsRequest, StatsType, TraceItemStatsRequest, + TraceItemStatsResponse, ) from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey, ExtrapolationMode +from sentry_protos.snuba.v1.trace_item_filter_pb2 import TraceItemFilter from sentry import options from sentry.api.api_owners import ApiOwner @@ -40,9 +42,10 @@ from sentry.search.eap.resolver import SearchResolver from sentry.search.eap.spans.attributes import SPANS_STATS_EXCLUDED_ATTRIBUTES from sentry.search.eap.spans.definitions import SPAN_DEFINITIONS -from sentry.search.eap.types import SearchResolverConfig, SupportedTraceItemType +from sentry.search.eap.types import EAPResponse, SearchResolverConfig, SupportedTraceItemType from sentry.search.eap.utils import can_expose_attribute, translate_internal_to_public_alias from sentry.search.events import fields +from sentry.search.events.types import SnubaParams from sentry.seer.endpoints.compare import compare_distributions from sentry.seer.signed_seer_api import SeerViewerContext from sentry.snuba.referrer import Referrer @@ -258,7 +261,7 @@ class AttributeDistributionsResponse(TypedDict): def query_attribute_distributions( - snuba_params, + snuba_params: SnubaParams, function_string: str, above: bool, query_1: str, @@ -385,7 +388,9 @@ def query_attribute_distributions( AttributeKey(name=attr_proto.name, type=AttributeKey.TYPE_STRING) ) - def run_stats_request_with_error_handling(filter, attributes): + def run_stats_request_with_error_handling( + filter: TraceItemFilter | None, attributes: list[AttributeKey] + ) -> TraceItemStatsResponse: with handle_query_errors(): request = TraceItemStatsRequest( filter=filter, @@ -401,7 +406,7 @@ def run_stats_request_with_error_handling(filter, attributes): ) return trace_item_stats_rpc(request) - def run_table_query_with_error_handling(query_string): + def run_table_query_with_error_handling(query_string: str) -> EAPResponse: with handle_query_errors(): return Spans.run_table_query( params=snuba_params, diff --git a/src/sentry/api/endpoints/prompts_activity.py b/src/sentry/api/endpoints/prompts_activity.py index 2497cb306a4d..287bdd4f00dd 100644 --- a/src/sentry/api/endpoints/prompts_activity.py +++ b/src/sentry/api/endpoints/prompts_activity.py @@ -55,7 +55,7 @@ class PromptsActivitySerializer(serializers.Serializer): help_text="The new prompt status: `snoozed`, `dismissed`, or `visible`.", ) - def validate_feature(self, value): + def validate_feature(self, value: str | None) -> str: if value is None: raise serializers.ValidationError("Must specify feature name") if not prompt_config.has(value): @@ -110,7 +110,7 @@ class PromptsActivityEndpoint(OrganizationEndpoint): }, ) def get( - self, request: Request, organization: Organization, **kwargs + self, request: Request, organization: Organization, **kwargs: object ) -> ( Response[PromptsActivityResponse] | Response[DetailResponse] @@ -171,7 +171,7 @@ def get( 404: RESPONSE_NOT_FOUND, }, ) - def put(self, request: Request, organization: Organization, **kwargs) -> Response: + def put(self, request: Request, organization: Organization, **kwargs: object) -> Response: """ Snooze, dismiss, or restore a feature prompt for the current user. """ diff --git a/src/sentry/api/serializers/models/release_file.py b/src/sentry/api/serializers/models/release_file.py index 77d8b89c97ec..8eb30d39cb54 100644 --- a/src/sentry/api/serializers/models/release_file.py +++ b/src/sentry/api/serializers/models/release_file.py @@ -1,4 +1,5 @@ from base64 import urlsafe_b64decode, urlsafe_b64encode +from collections.abc import Mapping from datetime import datetime from typing import TypedDict @@ -12,7 +13,7 @@ class ReleaseFileSerializerResponse(TypedDict): - id: str + id: str | bytes | None name: str dist: str | None headers: dict[str, object] @@ -21,7 +22,7 @@ class ReleaseFileSerializerResponse(TypedDict): dateCreated: datetime -def encode_release_file_id(obj): +def encode_release_file_id(obj: ReleaseFile) -> str | bytes | None: """Generate ID for artifacts that only exist in a bundle This ID is only unique per release. @@ -37,9 +38,10 @@ def encode_release_file_id(obj): if obj.dist_id: dist_name = Distribution.objects.get(pk=obj.dist_id).name return urlsafe_b64encode(f"{dist_name}_{obj.name}".encode()) + return None -def decode_release_file_id(id: str): +def decode_release_file_id(id: str) -> int | tuple[str | None, str]: """May raise ValueError""" try: return int(id) @@ -54,7 +56,7 @@ class ReleaseFileSerializer(Serializer[ReleaseFileSerializerResponse]): def serialize( self, obj: ReleaseFile, - attrs, + attrs: Mapping[object, object], user: User | RpcUser | AnonymousUser, **kwargs: object, ) -> ReleaseFileSerializerResponse: diff --git a/src/sentry/api/serializers/rest_framework/project.py b/src/sentry/api/serializers/rest_framework/project.py index c97da713cbf2..fd8a62f2c8f4 100644 --- a/src/sentry/api/serializers/rest_framework/project.py +++ b/src/sentry/api/serializers/rest_framework/project.py @@ -1,4 +1,7 @@ -from collections.abc import Collection +from __future__ import annotations + +from collections.abc import Collection, Sequence +from typing import TYPE_CHECKING, TypedDict, Unpack from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field @@ -7,14 +10,37 @@ from sentry.api.helpers.projects import ProjectIdOrSlugField from sentry.models.project import Project +if TYPE_CHECKING: + from django_stubs_ext import StrOrPromise + from rest_framework.fields import _DefaultInitial + from rest_framework.validators import Validator + ValidationError = serializers.ValidationError +class _ProjectFieldKwargs(TypedDict, total=False): + read_only: bool + write_only: bool + required: bool | None + default: _DefaultInitial[Project] + initial: _DefaultInitial[Project] + source: str | None + label: StrOrPromise | None + help_text: StrOrPromise | None + style: dict[str, object] | None + error_messages: dict[str, StrOrPromise] | None + validators: Sequence[Validator[Project]] | None + allow_null: bool + + @extend_schema_field(field=OpenApiTypes.STR) -class ProjectField(serializers.Field): +class ProjectField(serializers.Field[Project, object, Project, object]): def __init__( - self, scope: str | Collection[str] = "project:write", id_allowed: bool = False, **kwags - ): + self, + scope: str | Collection[str] = "project:write", + id_allowed: bool = False, + **kwags: Unpack[_ProjectFieldKwargs], + ) -> None: """ The scope parameter specifies which permissions are required to access the project field. If multiple scopes are provided, the project can be accessed when the user is authenticated with @@ -26,7 +52,7 @@ def __init__( self.id_allowed = id_allowed super().__init__(**kwags) - def to_representation(self, value): + def to_representation(self, value: Project) -> Project: return value def to_internal_value(self, data: object) -> Project: diff --git a/src/sentry/auth/provider.py b/src/sentry/auth/provider.py index 5e5d6e6ebcdf..c6c3fff031e1 100644 --- a/src/sentry/auth/provider.py +++ b/src/sentry/auth/provider.py @@ -6,6 +6,8 @@ from collections.abc import Callable, Mapping, Sequence from typing import TYPE_CHECKING, Any +from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth.models import AnonymousUser from django.http.request import HttpRequest from django.utils.encoding import force_str @@ -14,7 +16,6 @@ from sentry.models.authidentity import AuthIdentity from sentry.organizations.services.organization.model import RpcOrganization from sentry.pipeline.provider import PipelineProvider -from sentry.users.models.user import User if TYPE_CHECKING: from sentry.auth.helper import AuthHelper # noqa: F401 @@ -145,7 +146,7 @@ def refresh_identity(self, auth_identity: AuthIdentity) -> None: """ raise NotImplementedError - def can_use_scim(self, organization_id: int, user: User) -> bool: + def can_use_scim(self, organization_id: int, user: AbstractBaseUser | AnonymousUser) -> bool: """ Controls whether or not a provider can have SCIM enabled to manage users. By default we have this on for all providers. diff --git a/src/sentry/integrations/discord/integration.py b/src/sentry/integrations/discord/integration.py index ad4610fcfbdc..8e80cca6613e 100644 --- a/src/sentry/integrations/discord/integration.py +++ b/src/sentry/integrations/discord/integration.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, TypedDict from urllib.parse import urlencode from django.http.request import HttpRequest @@ -84,7 +84,7 @@ ) -class DiscordIntegration(IntegrationInstallation, IntegrationNotificationClient): +class DiscordIntegration(IntegrationInstallation, IntegrationNotificationClient[DiscordRenderable]): def get_client(self) -> DiscordClient: return DiscordClient() @@ -142,13 +142,25 @@ def uninstall(self) -> None: return -class DiscordOAuthApiSerializer(CamelSnakeSerializer): +class DiscordOAuthApiData(TypedDict): + code: str + state: str + guild_id: str + + +class DiscordOAuthApiSerializer(CamelSnakeSerializer[DiscordOAuthApiData]): code = CharField(required=True) state = CharField(required=True) guild_id = CharField(required=True) -class DiscordInitialDataSerializer(CamelSnakeSerializer): +class DiscordInitialData(TypedDict, total=False): + code: str + guild_id: str + use_configure: str + + +class DiscordInitialDataSerializer(CamelSnakeSerializer[DiscordInitialData]): """Initial pipeline data for App Directory-originated Discord installs. When a user installs from Discord's App Directory, Discord initiates OAuth diff --git a/src/sentry/integrations/models/repository_project_path_config.py b/src/sentry/integrations/models/repository_project_path_config.py index 13311aa42d95..b812d1a608d4 100644 --- a/src/sentry/integrations/models/repository_project_path_config.py +++ b/src/sentry/integrations/models/repository_project_path_config.py @@ -52,14 +52,14 @@ def __repr__(self) -> str: ) -def process_resource_change(instance: RepositoryProjectPathConfig, **kwargs): +def process_resource_change(instance: RepositoryProjectPathConfig, **kwargs: object) -> None: if instance._skip_post_save: return from sentry.models.project import Project from sentry.tasks.codeowners import update_code_owners_schema - def _spawn_update_schema_task(): + def _spawn_update_schema_task() -> None: """ We need to re-apply the updated code mapping against any CODEOWNERS file that uses this mapping. """ diff --git a/src/sentry/issues/endpoints/organization_issue_metrics.py b/src/sentry/issues/endpoints/organization_issue_metrics.py index d991ce846484..9529bbd45f51 100644 --- a/src/sentry/issues/endpoints/organization_issue_metrics.py +++ b/src/sentry/issues/endpoints/organization_issue_metrics.py @@ -5,7 +5,7 @@ from itertools import chain from typing import TypedDict -from django.db.models import Count, DateTimeField, F, Func, Q +from django.db.models import Count, DateTimeField, F, Func, Manager, Q, QuerySet from django.db.models.functions import Extract from drf_spectacular.utils import extend_schema from rest_framework.exceptions import ParseError @@ -135,12 +135,12 @@ def get(self, request: Request, organization: Organization) -> Response: raise ParseError("The specified granularity is too precise. Increase your interval.") def gen_ts( - qs, + qs: QuerySet[Group] | Manager[Group], group_by: list[str], date_column_name: str, axis: str, ) -> list[TimeSeriesResult]: - qs = make_timeseries_query( + rows = make_timeseries_query( qs, projects, environments, @@ -154,11 +154,13 @@ def gen_ts( grouped_counter: collections.defaultdict[str, int] = collections.defaultdict(int) grouped_series: dict[str, list[TimeSeries]] = collections.defaultdict(list) - for row in qs: - grouping = [row[g] for g in group_by] + for row in rows: + grouping = [str(row[g]) for g in group_by] key = "||||".join(grouping) - grouped_counter[key] += row["value"] - grouped_series[key].append({"timestamp": row["timestamp"], "value": row["value"]}) + grouped_counter[key] += int(row["value"]) + grouped_series[key].append( + {"timestamp": float(row["timestamp"]), "value": int(row["value"])} + ) # Group the smallest series into the "other" bucket. if len(grouped_series) > 4: @@ -245,7 +247,7 @@ def gen_ts( def make_timeseries_query( - qs, + qs: QuerySet[Group] | Manager[Group], projects: list[Project], environments: list[int], type_filter: Q, @@ -254,7 +256,7 @@ def make_timeseries_query( source: str, start: datetime, end: datetime, -): +) -> QuerySet[Group, dict[str, str | int | float]]: environment_filter = ( Q(groupenvironment__environment_id=environments[0]) if environments else Q() ) @@ -281,7 +283,7 @@ def make_timeseries_query( order_by.append("timestamp") values.append("timestamp") - qs = ( + rows = ( qs.filter( environment_filter, type_filter, @@ -293,7 +295,7 @@ def make_timeseries_query( .values(*values) .annotate(value=Count("id")) ) - return qs + return rows def make_timeseries_result( diff --git a/src/sentry/lang/java/utils.py b/src/sentry/lang/java/utils.py index 7fd54091924e..406fd82575db 100644 --- a/src/sentry/lang/java/utils.py +++ b/src/sentry/lang/java/utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from sentry.stacktraces.processing import StacktraceInfo @@ -14,15 +15,15 @@ JAVA_PLATFORMS = ("java",) -def is_valid_proguard_image(image): - return bool(image) and image.get("type") == "proguard" and image.get("uuid") is not None +def is_valid_proguard_image(image: Mapping[str, object] | None) -> bool: + return image is not None and image.get("type") == "proguard" and image.get("uuid") is not None -def is_valid_jvm_image(image): - return bool(image) and image.get("type") == "jvm" and image.get("debug_id") is not None +def is_valid_jvm_image(image: Mapping[str, object] | None) -> bool: + return image is not None and image.get("type") == "jvm" and image.get("debug_id") is not None -def has_proguard_file(data): +def has_proguard_file(data: Mapping[str, object]) -> bool: """ Checks whether an event contains a proguard file """ diff --git a/src/sentry/models/projectteam.py b/src/sentry/models/projectteam.py index 51f3c31ea96d..ce65dfcc2f29 100644 --- a/src/sentry/models/projectteam.py +++ b/src/sentry/models/projectteam.py @@ -42,11 +42,11 @@ class Meta: __repr__ = sane_repr("project_id", "team_id") -def process_resource_change(instance: ProjectTeam, **kwargs): +def process_resource_change(instance: ProjectTeam, **kwargs: object) -> None: from sentry.models.project import Project from sentry.tasks.codeowners import update_code_owners_schema - def _spawn_task(): + def _spawn_task() -> None: try: update_code_owners_schema.apply_async( kwargs={ diff --git a/src/sentry/models/releaseprojectenvironment.py b/src/sentry/models/releaseprojectenvironment.py index 2a73fcfe14be..dd6d5652b43f 100644 --- a/src/sentry/models/releaseprojectenvironment.py +++ b/src/sentry/models/releaseprojectenvironment.py @@ -2,7 +2,7 @@ from datetime import datetime from enum import Enum -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict from django.db import models from django.utils import timezone @@ -15,11 +15,17 @@ cell_silo_model, sane_repr, ) +from sentry.metrics.base import MutableTags from sentry.models.metric_tags import DATA_ACCESS_TAG, DataAccessTagValues from sentry.utils import metrics from sentry.utils.cache import cache from sentry.utils.last_seen import BumpResult, try_bump_last_seen +if TYPE_CHECKING: + from sentry.models.environment import Environment + from sentry.models.project import Project + from sentry.models.release import Release + class ReleaseStages(str, Enum): ADOPTED = "adopted" @@ -54,11 +60,19 @@ class Meta: __repr__ = sane_repr("project", "release", "environment") @classmethod - def get_cache_key(cls, release_id, project_id, environment_id) -> str: + def get_cache_key(cls, release_id: int, project_id: int, environment_id: int) -> str: return f"releaseprojectenv:{release_id}:{project_id}:{environment_id}" @classmethod - def get_or_create(cls, release, project, environment, datetime, metrics_tags=None, **kwargs): + def get_or_create( + cls, + release: Release, + project: Project, + environment: Environment, + datetime: datetime, + metrics_tags: MutableTags | None = None, + **kwargs: object, + ) -> ReleaseProjectEnvironment: with metrics.timer("models.releaseprojectenvironment.get_or_create") as timer_tags: instance = cls._get_or_create_impl( release, project, environment, datetime, timer_tags, **kwargs @@ -68,7 +82,15 @@ def get_or_create(cls, release, project, environment, datetime, metrics_tags=Non return instance @classmethod - def _get_or_create_impl(cls, release, project, environment, datetime, metrics_tags, **kwargs): + def _get_or_create_impl( + cls, + release: Release, + project: Project, + environment: Environment, + datetime: datetime, + metrics_tags: MutableTags, + **kwargs: object, + ) -> ReleaseProjectEnvironment: cache_key = cls.get_cache_key(project.id, release.id, environment.id) instance = cache.get(cache_key) diff --git a/src/sentry/release_health/tasks.py b/src/sentry/release_health/tasks.py index 469a39848919..3ecd6b3f774f 100644 --- a/src/sentry/release_health/tasks.py +++ b/src/sentry/release_health/tasks.py @@ -42,7 +42,7 @@ retry=Retry(times=5, on=(Exception,)), processing_deadline_duration=400, ) -def monitor_release_adoption(**kwargs) -> None: +def monitor_release_adoption(**kwargs: object) -> None: metrics.incr("sentry.tasks.monitor_release_adoption.start", sample_rate=1.0) with metrics.timer( "sentry.tasks.monitor_release_adoption.process_projects_with_sessions", sample_rate=1.0 @@ -68,7 +68,7 @@ def get_process_projects_with_sessions_countdown(org_id: int) -> int: retry=Retry(times=5, on=(Exception,), delay=5), processing_deadline_duration=160, ) -def process_projects_with_sessions(org_id, project_ids) -> None: +def process_projects_with_sessions(org_id: int, project_ids: Sequence[int]) -> None: # Takes a single org id and a list of project ids with metrics.timer("sentry.tasks.monitor_release_adoption.process_projects_with_sessions.core"): @@ -143,8 +143,13 @@ def adopt_releases(org_id: int, totals: Totals) -> Sequence[int]: def _update_last_seen( - org_id, current_time, cutoff_time, project_id, environment_name, release_version -): + org_id: int, + current_time: datetime, + cutoff_time: datetime, + project_id: int, + environment_name: str, + release_version: str, +) -> None: # Skip last_seen updates when the disable option is set (default True) if options.get("release-health.disable-release-last-seen-update", True): return diff --git a/src/sentry/sentry_apps/alert_rule_action_creator.py b/src/sentry/sentry_apps/alert_rule_action_creator.py index d0ade2a4c5c4..9ca89b08c134 100644 --- a/src/sentry/sentry_apps/alert_rule_action_creator.py +++ b/src/sentry/sentry_apps/alert_rule_action_creator.py @@ -9,6 +9,7 @@ SentryAppAlertRuleActionRequester, SentryAppAlertRuleActionResult, ) +from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.sentry_apps.models.sentry_app_component import SentryAppComponent from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation from sentry.sentry_apps.utils.errors import SentryAppErrorType @@ -25,14 +26,14 @@ def run(self) -> SentryAppAlertRuleActionResult: response = self._make_external_request(uri) return response - def _fetch_sentry_app_uri(self): + def _fetch_sentry_app_uri(self) -> str | None: component = SentryAppComponent.objects.get( type="alert-rule-action", sentry_app=self.sentry_app ) settings = component.schema.get("settings", {}) return settings.get("uri") - def _make_external_request(self, uri=None): + def _make_external_request(self, uri: str | None = None) -> SentryAppAlertRuleActionResult: if uri is None: return SentryAppAlertRuleActionResult( message="Request url for alert-rule-action not found, please check your integration schema", @@ -49,5 +50,5 @@ def _make_external_request(self, uri=None): return response @cached_property - def sentry_app(self): + def sentry_app(self) -> SentryApp: return self.install.sentry_app diff --git a/src/sentry/snuba/metrics_enhanced_performance.py b/src/sentry/snuba/metrics_enhanced_performance.py index 1f6dc61049a4..b177807c0adf 100644 --- a/src/sentry/snuba/metrics_enhanced_performance.py +++ b/src/sentry/snuba/metrics_enhanced_performance.py @@ -3,7 +3,7 @@ import types from collections.abc import Sequence from datetime import timedelta -from typing import Any +from typing import Any, Literal import sentry_sdk from snuba_sdk import Column, Condition @@ -146,12 +146,12 @@ def timeseries_query( rollup: int, referrer: str, zerofill_results: bool = True, - allow_metric_aggregates=True, + allow_metric_aggregates: bool = True, comparison_delta: timedelta | None = None, functions_acl: list[str] | None = None, has_metrics: bool = True, on_demand_metrics_enabled: bool = False, - on_demand_metrics_type=None, + on_demand_metrics_type: MetricSpecType | None = None, query_source: QuerySource | None = None, fallback_to_transactions: bool = False, transform_alias_to_input_format: bool = False, @@ -332,26 +332,26 @@ def top_events_timeseries( def histogram_query( - fields, - user_query, - snuba_params, - num_buckets, - precision=0, - min_value=None, - max_value=None, - data_filter=None, - group_by=None, - order_by=None, - limit_by=None, - histogram_rows=None, - extra_conditions=None, - normalize_results=True, - on_demand_metrics_enabled=False, - on_demand_metrics_type=None, + fields: list[str], + user_query: str, + snuba_params: SnubaParams, + num_buckets: int, + precision: int = 0, + min_value: float | None = None, + max_value: float | None = None, + data_filter: Literal["exclude_outliers"] | None = None, + group_by: list[str] | None = None, + order_by: list[str] | None = None, + limit_by: list[str] | None = None, + histogram_rows: int | None = None, + extra_conditions: list[Condition] | None = None, + normalize_results: bool = True, + on_demand_metrics_enabled: bool = False, + on_demand_metrics_type: MetricSpecType | None = None, query_source: QuerySource | None = None, *, referrer: str, -): +) -> dict[str, object] | EventsResponse: """ High-level API for doing arbitrary user timeseries queries against events. this API should match that of sentry.snuba.discover.histogram_query diff --git a/src/sentry/tempest/serializers.py b/src/sentry/tempest/serializers.py index 625e636be2de..6bd13dc3543e 100644 --- a/src/sentry/tempest/serializers.py +++ b/src/sentry/tempest/serializers.py @@ -1,7 +1,12 @@ +from collections.abc import Mapping, MutableMapping, Sequence + +from django.contrib.auth.models import AnonymousUser from rest_framework import serializers from sentry.api.serializers.base import Serializer, register from sentry.tempest.models import TempestCredentials +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service @@ -12,24 +17,36 @@ def _obfuscate_client_secret(self, client_secret: str) -> str: def get_attrs( self, - item_list, - user, - **kwargs, - ): + item_list: Sequence[TempestCredentials], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> MutableMapping[TempestCredentials, dict[str, RpcUser | None]]: users_mapping = {} user_ids = [item.created_by_id for item in item_list if item.created_by_id is not None] users = user_service.get_many_by_id(ids=user_ids) for rpc_user in users: users_mapping[rpc_user.id] = rpc_user - attrs = {} + attrs: MutableMapping[TempestCredentials, dict[str, RpcUser | None]] = {} for item in item_list: - attrs[item] = users_mapping.get(item.created_by_id) + attrs[item] = { + "user": ( + users_mapping.get(item.created_by_id) + if item.created_by_id is not None + else None + ) + } return attrs - def serialize(self, obj, attrs, user, **kwargs): - rpc_user = attrs + def serialize( + self, + obj: TempestCredentials, + attrs: Mapping[str, RpcUser | None], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> Mapping[str, object]: + rpc_user = attrs["user"] return { "id": obj.id, "clientId": obj.client_id, @@ -44,7 +61,7 @@ def serialize(self, obj, attrs, user, **kwargs): } -class DRFTempestCredentialsSerializer(serializers.ModelSerializer): +class DRFTempestCredentialsSerializer(serializers.ModelSerializer[TempestCredentials]): clientId = serializers.CharField(source="client_id") clientSecret = serializers.CharField(source="client_secret") message = serializers.CharField(read_only=True) diff --git a/src/sentry/uptime/config_producer.py b/src/sentry/uptime/config_producer.py index ba4b0e16432d..e6cd7b722235 100644 --- a/src/sentry/uptime/config_producer.py +++ b/src/sentry/uptime/config_producer.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) -def produce_config(destination_region_slug: str, config: CheckConfig): +def produce_config(destination_region_slug: str, config: CheckConfig) -> None: jsonschema.validate(config, CHECK_CONFIG_SCHEMA) _send_to_redis( destination_region_slug, @@ -27,7 +27,7 @@ def produce_config(destination_region_slug: str, config: CheckConfig): ) -def produce_config_removal(destination_region_slug: str, subscription_id: str): +def produce_config_removal(destination_region_slug: str, subscription_id: str) -> None: _send_to_redis(destination_region_slug, UUID(subscription_id), None) @@ -60,7 +60,7 @@ def _send_to_redis( key = subscription_id.hex config_key, update_key = get_partition_keys(subscription_id, region_config) - cluster: RedisCluster | StrictRedis = redis.redis_clusters.get_binary( + cluster: RedisCluster[bytes] | StrictRedis[bytes] = redis.redis_clusters.get_binary( region_config.config_redis_cluster ) pipe = cluster.pipeline() diff --git a/src/sentry/utils/last_seen.py b/src/sentry/utils/last_seen.py index af41b338dcb6..70f2dd4b8687 100644 --- a/src/sentry/utils/last_seen.py +++ b/src/sentry/utils/last_seen.py @@ -8,6 +8,8 @@ from django.core.cache import cache from django.db.utils import OperationalError +from sentry.metrics.base import MutableTags + LAST_SEEN_INTERVAL_SECONDS = 60 @@ -36,7 +38,7 @@ def try_bump_last_seen( datetime: dt, bump_key: str, cache_key: str, - metrics_tags: dict[str, str], + metrics_tags: MutableTags, ) -> BumpResult: """Throttled last_seen bump — at most once per 60s per row via a cache-based lock.""" if instance.last_seen >= datetime - timedelta(seconds=LAST_SEEN_INTERVAL_SECONDS): diff --git a/src/sentry/web/frontend/organization_auth_settings.py b/src/sentry/web/frontend/organization_auth_settings.py index ed3b9e56e339..225d77161e2f 100644 --- a/src/sentry/web/frontend/organization_auth_settings.py +++ b/src/sentry/web/frontend/organization_auth_settings.py @@ -13,6 +13,7 @@ from sentry import audit_log, features, roles from sentry.auth import manager from sentry.auth.helper import AuthHelper +from sentry.auth.provider import Provider from sentry.auth.providers.saml2.generic.provider import GenericSAML2Provider from sentry.auth.services.auth import RpcAuthProvider, auth_service from sentry.auth.store import FLOW_SETUP_PROVIDER @@ -36,7 +37,12 @@ logger = logging.getLogger("sentry.saml_setup_error") -def auth_provider_settings_form(provider, auth_provider, organization, request): +def auth_provider_settings_form( + provider: Provider, + auth_provider: RpcAuthProvider, + organization: RpcOrganization, + request: HttpRequest, +) -> forms.Form: # Determine the default role choices the current user is allowed to assign if is_active_superuser(request): role_choices = roles.get_choices() @@ -119,7 +125,7 @@ class OrganizationAuthSettingsView(ControlSiloOrganizationView): def _disable_provider( self, request: HttpRequest, organization: RpcOrganization, auth_provider: RpcAuthProvider - ): + ) -> None: user = request.user sending_email = "" if hasattr(user, "email"): @@ -140,7 +146,7 @@ def _disable_provider( def handle_existing_provider( self, request: HttpRequest, organization: RpcOrganization, auth_provider: RpcAuthProvider - ): + ) -> HttpResponseBase: provider = auth_provider.get_provider() if request.method == "POST": diff --git a/src/social_auth/views.py b/src/social_auth/views.py index 8024e4137983..7f25760a9317 100644 --- a/src/social_auth/views.py +++ b/src/social_auth/views.py @@ -9,12 +9,14 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.decorators import login_required -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.utils.http import url_has_allowed_host_and_scheme from django.views.decorators.csrf import csrf_exempt from sentry.web.frontend.base import control_silo_view +from social_auth.backends import BaseAuth from social_auth.decorators import dsa_view from social_auth.exceptions import AuthException from social_auth.utils import backend_setting, clean_partial_pipeline, setting @@ -26,7 +28,7 @@ @control_silo_view @dsa_view(setting("SOCIAL_AUTH_COMPLETE_URL_NAME", "socialauth_associate_complete_auth_sso")) -def auth(request, backend): +def auth(request: HttpRequest, backend: BaseAuth) -> HttpResponse: """Authenticate using social backend""" data = request.POST if request.method == "POST" else request.GET @@ -59,7 +61,9 @@ def auth(request, backend): @csrf_exempt @login_required @dsa_view() -def complete(request, backend, *args, **kwargs): +def complete( + request: HttpRequest, backend: BaseAuth, *args: object, **kwargs: object +) -> HttpResponse: """Authentication complete process""" # pop redirect value before the session is trashed on login() redirect_value = request.session.get(REDIRECT_FIELD_NAME, "") @@ -92,7 +96,13 @@ def complete(request, backend, *args, **kwargs): @control_silo_view -def auth_complete(request, backend, user, *args, **kwargs): +def auth_complete( + request: HttpRequest, + backend: BaseAuth, + user: AbstractBaseUser | None, + *args: object, + **kwargs: object, +) -> AbstractBaseUser | HttpResponse | None: """Complete auth process. Return authenticated user or None.""" if request.session.get(PIPELINE_KEY): data = request.session.pop(PIPELINE_KEY) From 3b518c9ee3bbc5694045de7535ef03e9313281d1 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:19:51 -0700 Subject: [PATCH 031/103] ref(typing): remove lane 6 weaklist entries Integrate lane 6 and remove its 19 weaklist entries. Fix the three cross-module callers exposed by the stricter signatures. --- pyproject.toml | 19 ------------------- src/sentry/event_manager.py | 5 +++-- .../endpoints/project_release_file_details.py | 7 ++++--- .../frontend/organization_auth_settings.py | 2 +- 4 files changed, 8 insertions(+), 25 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1dc8c738679f..8d31f84585a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -459,7 +459,6 @@ module = [ "sentry.api.endpoints.assistant", "sentry.api.endpoints.auth_config", "sentry.api.endpoints.auth_index", - "sentry.api.endpoints.auth_login", "sentry.api.endpoints.broadcast_index", "sentry.api.endpoints.builtin_symbol_sources", "sentry.api.endpoints.chunk", @@ -471,13 +470,11 @@ module = [ "sentry.api.endpoints.organization_onboarding_continuation_email", "sentry.api.endpoints.organization_profiling_functions", "sentry.api.endpoints.organization_releases", - "sentry.api.endpoints.organization_sampling_project_rates", "sentry.api.endpoints.organization_sdk_updates", "sentry.api.endpoints.organization_sessions", "sentry.api.endpoints.organization_stats_summary", "sentry.api.endpoints.organization_stats_v2", "sentry.api.endpoints.organization_trace_item_attributes", - "sentry.api.endpoints.organization_trace_item_attributes_ranked", "sentry.api.endpoints.organization_trace_item_stats", "sentry.api.endpoints.organization_traces", "sentry.api.endpoints.organization_unsubscribe", @@ -489,7 +486,6 @@ module = [ "sentry.api.endpoints.project_trace_item_details", "sentry.api.endpoints.project_transaction_threshold", "sentry.api.endpoints.project_transaction_threshold_override", - "sentry.api.endpoints.prompts_activity", "sentry.api.endpoints.relay.*", "sentry.api.exceptions", "sentry.api.fields.*", @@ -504,13 +500,11 @@ module = [ "sentry.api.serializers.models.project", "sentry.api.serializers.models.pullrequest", "sentry.api.serializers.models.release", - "sentry.api.serializers.models.release_file", "sentry.api.serializers.models.userrollback", "sentry.api.serializers.rest_framework.base", "sentry.api.serializers.rest_framework.dashboard", "sentry.api.serializers.rest_framework.environment", "sentry.api.serializers.rest_framework.groupsearchview", - "sentry.api.serializers.rest_framework.project", "sentry.api.serializers.rest_framework.release", "sentry.api.serializers.snuba", "sentry.api.validators.*", @@ -587,7 +581,6 @@ module = [ "sentry.integrations.cursor.*", "sentry.integrations.data_forwarding.*", "sentry.integrations.discord.client", - "sentry.integrations.discord.integration", "sentry.integrations.discord.requests.*", "sentry.integrations.example.*", "sentry.integrations.github.client", @@ -614,7 +607,6 @@ module = [ "sentry.integrations.mixins.*", "sentry.integrations.models.external_actor", "sentry.integrations.models.integration", - "sentry.integrations.models.repository_project_path_config", "sentry.integrations.msteams.actions.*", "sentry.integrations.msteams.card_builder.*", "sentry.integrations.msteams.client", @@ -639,7 +631,6 @@ module = [ "sentry.issues.attributes", "sentry.issues.endpoints.group_current_release", "sentry.issues.endpoints.organization_event_details", - "sentry.issues.endpoints.organization_issue_metrics", "sentry.issues.endpoints.organization_issue_timeseries", "sentry.issues.endpoints.organization_issues_count", "sentry.issues.endpoints.project_ownership", @@ -650,7 +641,6 @@ module = [ "sentry.killswitches", "sentry.lang.dart.*", "sentry.lang.java.exceptions", - "sentry.lang.java.utils", "sentry.lang.java.view_hierarchies", "sentry.lang.javascript.*", "sentry.lang.native.*", @@ -687,14 +677,12 @@ module = [ "sentry.models.projectcodeowners", "sentry.models.projectkey", "sentry.models.projectownership", - "sentry.models.projectteam", "sentry.models.recentsearch", "sentry.models.relay", "sentry.models.release", "sentry.models.release_threshold.*", "sentry.models.releaseenvironment", "sentry.models.releasefile", - "sentry.models.releaseprojectenvironment", "sentry.models.releases.*", "sentry.models.repository", "sentry.models.rule", @@ -737,7 +725,6 @@ module = [ "sentry.relay.projectconfig_debounce_cache.*", "sentry.relay.utils", "sentry.release_health.eap_sessions_rollout", - "sentry.release_health.tasks", "sentry.releases.endpoints.*", "sentry.remote_subscriptions.*", "sentry.replays._case_studies.*", @@ -766,7 +753,6 @@ module = [ "sentry.seer.entrypoints.*", "sentry.seer.models.*", "sentry.seer.supergroups.*", - "sentry.sentry_apps.alert_rule_action_creator", "sentry.sentry_apps.api.bases.*", "sentry.sentry_apps.api.endpoints.*", "sentry.sentry_apps.api.parsers.*", @@ -786,7 +772,6 @@ module = [ "sentry.snuba.metrics.fields.*", "sentry.snuba.metrics.mqb_query_transformer", "sentry.snuba.metrics.query_builder", - "sentry.snuba.metrics_enhanced_performance", "sentry.snuba.metrics_performance", "sentry.snuba.outcomes", "sentry.snuba.sessions_v2", @@ -818,7 +803,6 @@ module = [ "sentry.tasks.statistical_detectors", "sentry.tasks.summaries.*", "sentry.tasks.user_report", - "sentry.tempest.serializers", "sentry.tempest.tasks", "sentry.templatetags.*", "sentry.testutils.asserts", @@ -847,7 +831,6 @@ module = [ "sentry.toolbar.views.*", "sentry.tsdb.*", "sentry.uptime.autodetect.*", - "sentry.uptime.config_producer", "sentry.uptime.consumers.*", "sentry.uptime.endpoints.*", "sentry.uptime.models", @@ -879,7 +862,6 @@ module = [ "sentry.web.frontend.mailgun_inbound_webhook", "sentry.web.frontend.oauth_token", "sentry.web.frontend.openidtoken", - "sentry.web.frontend.organization_auth_settings", "sentry.web.frontend.react_page", "sentry.web.frontend.release_webhook", "sentry.web.frontend.setup_wizard", @@ -889,7 +871,6 @@ module = [ "social_auth.fields", "social_auth.models", "social_auth.utils", - "social_auth.views", "tests", "tests.conftest", "tests.integration.*", diff --git a/src/sentry/event_manager.py b/src/sentry/event_manager.py index 9e9c77bf1b88..a15528476d5b 100644 --- a/src/sentry/event_manager.py +++ b/src/sentry/event_manager.py @@ -101,6 +101,7 @@ from sentry.issues.producer import PayloadType, produce_occurrence_to_kafka from sentry.killswitches import killswitch_matches_context from sentry.lang.native.utils import STORE_CRASH_REPORTS_ALL, convert_crashreport_count +from sentry.metrics.base import TagValue from sentry.models.activity import Activity from sentry.models.environment import Environment from sentry.models.event import EventDict @@ -757,7 +758,7 @@ def _set_project_platform_if_needed(project: Project, event: Event) -> None: # How often each cache-fronted model lookup on the save path reached Postgres. # `data_access` is set by the model: cache_hit, db_read, db_create, or db_update. -def _record_resolve_model(model: str, tags: dict[str, str]) -> None: +def _record_resolve_model(model: str, tags: Mapping[str, TagValue]) -> None: metrics.incr( "save_event.resolve_model", tags={ @@ -1008,7 +1009,7 @@ def _get_or_create_release_associated_models( ) _record_resolve_model("releaseenvironment", release_env_tags) - release_project_env_tags: dict[str, str] = {} + release_project_env_tags: MutableTags = {} ReleaseProjectEnvironment.get_or_create( project=project, release=release, diff --git a/src/sentry/releases/endpoints/project_release_file_details.py b/src/sentry/releases/endpoints/project_release_file_details.py index 8884c5a2ce3b..6cd8c36b7fd9 100644 --- a/src/sentry/releases/endpoints/project_release_file_details.py +++ b/src/sentry/releases/endpoints/project_release_file_details.py @@ -140,13 +140,14 @@ def _get_releasefile(release: Release, file_id: str, index_op=_get_from_index): except ReleaseFile.DoesNotExist: raise ResourceDoesNotExist else: - dist, url = id - if dist is not None: + dist_name, url = id + dist: Distribution | None = None + if dist_name is not None: # NOTE: Could do one less query if `read_artifact_index` accepted # `dist_name` try: dist = Distribution.objects.get( - organization_id=release.organization_id, name=dist, release=release + organization_id=release.organization_id, name=dist_name, release=release ) except Distribution.DoesNotExist: raise ResourceDoesNotExist diff --git a/src/sentry/web/frontend/organization_auth_settings.py b/src/sentry/web/frontend/organization_auth_settings.py index 225d77161e2f..07c30618e1a5 100644 --- a/src/sentry/web/frontend/organization_auth_settings.py +++ b/src/sentry/web/frontend/organization_auth_settings.py @@ -39,7 +39,7 @@ def auth_provider_settings_form( provider: Provider, - auth_provider: RpcAuthProvider, + auth_provider: AuthProvider | RpcAuthProvider, organization: RpcOrganization, request: HttpRequest, ) -> forms.Form: From 74cd243f84506bc796628a04e83f03ed6fb4a888 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:15:36 -0700 Subject: [PATCH 032/103] ref(typing): graduate lane 4 weaklist modules --- src/sentry/api/endpoints/artifact_bundles.py | 7 +-- .../api/endpoints/event_attachment_details.py | 19 ++++++-- .../api/endpoints/organization_sdk_updates.py | 48 ++++++++++++++----- .../endpoints/project_servicehook_details.py | 12 +++-- .../serializers/models/discoversavedquery.py | 22 ++++----- .../serializers/rest_framework/environment.py | 8 ++-- src/sentry/flags/models.py | 6 +-- src/sentry/integrations/github/issue_sync.py | 11 +++-- .../integrations/msteams/integration.py | 6 +-- .../organization_issue_timeseries.py | 17 +++---- src/sentry/models/authidentity.py | 6 +-- src/sentry/models/relay.py | 15 ++++-- src/sentry/monitors/clock_dispatch.py | 8 ++-- src/sentry/replays/data_export.py | 6 +-- src/sentry/signals.py | 41 +++++++++++++--- src/sentry/tasks/base.py | 6 +-- src/sentry/tempest/tasks.py | 6 +-- .../web/frontend/auth_organization_login.py | 6 ++- src/social_auth/decorators.py | 19 +++++--- 19 files changed, 182 insertions(+), 87 deletions(-) diff --git a/src/sentry/api/endpoints/artifact_bundles.py b/src/sentry/api/endpoints/artifact_bundles.py index 7807d2915b62..bf3e4506b3b7 100644 --- a/src/sentry/api/endpoints/artifact_bundles.py +++ b/src/sentry/api/endpoints/artifact_bundles.py @@ -17,6 +17,7 @@ from sentry.api.serializers import serialize from sentry.api.serializers.models.artifactbundle import ArtifactBundlesSerializer from sentry.models.artifactbundle import ArtifactBundle, ProjectArtifactBundle +from sentry.models.project import Project from sentry.utils.db import atomic_transaction # We want to keep a mapping of the fields that the frontend uses for filtering since we want to align the UI names and @@ -43,7 +44,7 @@ def derive_order_by(cls, sort_by: str) -> str | None: raise InvalidSortByParameter @classmethod - def is_valid_uuid(cls, value): + def is_valid_uuid(cls, value: object) -> bool: try: uuid.UUID(str(value)) return True @@ -60,7 +61,7 @@ class ArtifactBundlesEndpoint(ProjectEndpoint, ArtifactBundlesMixin): owner = ApiOwner.COMMUNITY permission_classes = (ProjectReleasePermission,) - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project: Project) -> Response: """ List a Project's Artifact Bundles ```````````````````````````````````` @@ -125,7 +126,7 @@ def get(self, request: Request, project) -> Response: on_results=lambda r: serialize(r, request.user, ArtifactBundlesSerializer()), ) - def delete(self, request: Request, project) -> Response: + def delete(self, request: Request, project: Project) -> Response: """ Delete an Archive ``````````````````````````````````````````````````` diff --git a/src/sentry/api/endpoints/event_attachment_details.py b/src/sentry/api/endpoints/event_attachment_details.py index fa1dbfea3257..fc04bdce8682 100644 --- a/src/sentry/api/endpoints/event_attachment_details.py +++ b/src/sentry/api/endpoints/event_attachment_details.py @@ -1,11 +1,13 @@ import posixpath -from typing import Any +from collections.abc import Iterator +from typing import Any, cast import sentry_sdk from django.http import HttpResponseRedirect, StreamingHttpResponse from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.views import APIView from sentry import features, roles from sentry.api.api_owners import ApiOwner @@ -29,9 +31,14 @@ from sentry.issues.action_log.request import resolve_action_actor, resolve_action_source from sentry.models.activity import Activity from sentry.models.eventattachment import EventAttachment +from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember from sentry.models.project import Project from sentry.objectstore import parse_accept_encoding +from sentry.organizations.services.organization import ( + RpcOrganization, + RpcUserOrganizationContext, +) from sentry.services import eventstore from sentry.types.activity import ActivityType @@ -59,7 +66,13 @@ class EventAttachmentDetailsPermission(ProjectPermission): - def has_object_permission(self, request: Request, view, project): + def has_object_permission( + self, + request: Request, + view: APIView, + project: Project | Organization | RpcOrganization | RpcUserOrganizationContext, + ) -> bool: + project = cast(Project, project) result = super().has_object_permission(request, view, project) if not result: @@ -113,7 +126,7 @@ def download( accept_encoding = parse_accept_encoding(request.headers.get("Accept-Encoding", "")) blob_stream = attachment.get_blob_stream(accept_encoding) - def stream_attachment(): + def stream_attachment() -> Iterator[bytes]: with blob_stream.payload as payload: while chunk := payload.read(4096): yield chunk diff --git a/src/sentry/api/endpoints/organization_sdk_updates.py b/src/sentry/api/endpoints/organization_sdk_updates.py index a75a94229ae6..79e435dd5865 100644 --- a/src/sentry/api/endpoints/organization_sdk_updates.py +++ b/src/sentry/api/endpoints/organization_sdk_updates.py @@ -1,5 +1,7 @@ +from collections.abc import Sequence from datetime import timedelta from itertools import chain, groupby +from typing import TypedDict, cast import sentry_sdk from django.utils import timezone @@ -14,23 +16,43 @@ from sentry.api.bases.organization import OrganizationEndpoint from sentry.api.utils import handle_query_errors from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.sdk_updates import SdkIndexState, SdkSetupState, get_sdk_index, get_suggested_updates from sentry.search.events.types import SnubaParams from sentry.snuba import discover from sentry.utils.numbers import format_grouped_length +SdkUsageRow = TypedDict( + "SdkUsageRow", + { + "project.id": int, + "sdk.name": str, + "sdk.version": str, + }, +) -def by_sdk_name(sdk): + +class SdkLatest(TypedDict): + projectId: str + sdkName: str + sdkVersion: str + + +class SdkUpdate(SdkLatest): + suggestions: list[dict[str, object]] + + +def by_sdk_name(sdk: SdkUsageRow) -> str: return sdk["sdk.name"] -def by_project_id(sdk): +def by_project_id(sdk: SdkUsageRow) -> int: return sdk["project.id"] -def serialize(data, projects): +def serialize(data: Sequence[SdkUsageRow], projects: Sequence[Project]) -> list[SdkUpdate]: # filter out SDKs with empty sdk.name or sdk.version or invalid version - nonempty_sdks = [] + nonempty_sdks: list[SdkUsageRow] = [] for sdk in data: if not sdk["sdk.name"] or not sdk["sdk.version"]: continue @@ -46,11 +68,11 @@ def serialize(data, projects): # project we have events for. latest_sdks = chain.from_iterable( [ - { - "projectId": str(project_id), - "sdkName": sdk_name, - "sdkVersion": max((s["sdk.version"] for s in sdks), key=version.parse), - } + SdkLatest( + projectId=str(project_id), + sdkName=sdk_name, + sdkVersion=max((s["sdk.version"] for s in sdks), key=version.parse), + ) for sdk_name, sdks in groupby(sorted(sdks_used, key=by_sdk_name), key=by_sdk_name) ] for project_id, sdks_used in groupby(nonempty_sdks, key=by_project_id) @@ -60,8 +82,10 @@ def serialize(data, projects): index_state = SdkIndexState() updates_list = [ - dict( - **latest, + SdkUpdate( + projectId=latest["projectId"], + sdkName=latest["sdkName"], + sdkVersion=latest["sdkVersion"], suggestions=list( get_suggested_updates( # TODO: In the future it would be nice to also add @@ -121,7 +145,7 @@ def get(self, request: Request, organization: Organization) -> Response: referrer="api.organization-sdk-updates", ) - return Response(serialize(result["data"], projects)) + return Response(serialize(cast(Sequence[SdkUsageRow], result["data"]), projects)) @cell_silo_endpoint diff --git a/src/sentry/api/endpoints/project_servicehook_details.py b/src/sentry/api/endpoints/project_servicehook_details.py index 210f19c2b553..651eacb93c0c 100644 --- a/src/sentry/api/endpoints/project_servicehook_details.py +++ b/src/sentry/api/endpoints/project_servicehook_details.py @@ -25,7 +25,9 @@ class ProjectServiceHookDetailsEndpoint(ServiceHookEndpoint): "PUT": ApiPublishStatus.PRIVATE, } - def get(self, request: Request, project: Project, hook: ServiceHook, **kwargs) -> Response: + def get( + self, request: Request, project: Project, hook: ServiceHook, **kwargs: object + ) -> Response: """ Retrieve a Service Hook ``````````````````````` @@ -41,7 +43,9 @@ def get(self, request: Request, project: Project, hook: ServiceHook, **kwargs) - """ return self.respond(serialize(hook, request.user, ServiceHookSerializer())) - def put(self, request: Request, project: Project, hook: ServiceHook, **kwargs) -> Response: + def put( + self, request: Request, project: Project, hook: ServiceHook, **kwargs: object + ) -> Response: """ Update a Service Hook ````````````````````` @@ -89,7 +93,9 @@ def put(self, request: Request, project: Project, hook: ServiceHook, **kwargs) - return self.respond(serialize(hook, request.user, ServiceHookSerializer())) - def delete(self, request: Request, project: Project, hook: ServiceHook, **kwargs) -> Response: + def delete( + self, request: Request, project: Project, hook: ServiceHook, **kwargs: object + ) -> Response: """ Remove a Service Hook ````````````````````` diff --git a/src/sentry/api/serializers/models/discoversavedquery.py b/src/sentry/api/serializers/models/discoversavedquery.py index 3d9cc332b957..e63ca38742f1 100644 --- a/src/sentry/api/serializers/models/discoversavedquery.py +++ b/src/sentry/api/serializers/models/discoversavedquery.py @@ -1,7 +1,7 @@ from collections import defaultdict -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from datetime import datetime -from typing import TypedDict +from typing import TypedDict, cast from django.contrib.auth.models import AnonymousUser @@ -41,7 +41,7 @@ class DiscoverSavedQueryResponseOptional(TypedDict, total=False): display: str topEvents: int interval: str - exploreQuery: dict | None + exploreQuery: dict[str, object] | None lastVisited: datetime | None starred: bool position: int | None @@ -62,7 +62,7 @@ class DiscoverSavedQueryResponse(DiscoverSavedQueryResponseOptional): @register(DiscoverSavedQuery) class DiscoverSavedQueryModelSerializer(Serializer[DiscoverSavedQueryResponse]): - def partial_serialize_explore_query(self, query: ExploreSavedQuery) -> dict: + def partial_serialize_explore_query(self, query: ExploreSavedQuery) -> dict[str, object]: query_keys = [ "environment", "query", @@ -71,7 +71,7 @@ def partial_serialize_explore_query(self, query: ExploreSavedQuery) -> dict: "end", "interval", ] - data = { + data: dict[str, object] = { "id": str(query.id), "name": query.name, "projects": [project.id for project in query.projects.all()], @@ -187,7 +187,7 @@ def get_attrs( def serialize( self, obj: DiscoverSavedQuery, - attrs, + attrs: Mapping[str, object], user: User | RpcUser | AnonymousUser, **kwargs: object, ) -> DiscoverSavedQueryResponse: @@ -218,17 +218,17 @@ def serialize( "expired": False, "dateCreated": obj.date_created, "dateUpdated": obj.date_updated, - "createdBy": attrs.get("created_by"), + "createdBy": cast(UserSerializerResponse | None, attrs.get("created_by")), } if "user_last_visited" in attrs: - data["lastVisited"] = attrs["user_last_visited"] + data["lastVisited"] = cast(datetime | None, attrs["user_last_visited"]) if "starred" in attrs: - data["starred"] = attrs["starred"] + data["starred"] = cast(bool, attrs["starred"]) if "position" in attrs: - data["position"] = attrs["position"] + data["position"] = cast(int | None, attrs["position"]) for key in query_keys: if obj.query.get(key) is not None: @@ -248,6 +248,6 @@ def serialize( data["projects"] = list(ALL_ACCESS_PROJECTS) if attrs.get("explore_query") is not None: - data["exploreQuery"] = attrs.get("explore_query") + data["exploreQuery"] = cast(dict[str, object] | None, attrs.get("explore_query")) return data diff --git a/src/sentry/api/serializers/rest_framework/environment.py b/src/sentry/api/serializers/rest_framework/environment.py index 3550c308dd97..f90a7405f807 100644 --- a/src/sentry/api/serializers/rest_framework/environment.py +++ b/src/sentry/api/serializers/rest_framework/environment.py @@ -7,11 +7,13 @@ @extend_schema_field(str) -class EnvironmentField(serializers.Field): - def to_representation(self, value): +class EnvironmentField( + serializers.Field[Environment | None, str | None, Environment | None, object] +): + def to_representation(self, value: Environment | None) -> Environment | None: return value - def to_internal_value(self, data): + def to_internal_value(self, data: str | None) -> Environment | None: if data is None: return None try: diff --git a/src/sentry/flags/models.py b/src/sentry/flags/models.py index fd3b36e3fd6e..96e84fd8b0b9 100644 --- a/src/sentry/flags/models.py +++ b/src/sentry/flags/models.py @@ -16,7 +16,7 @@ class ActionEnum(Enum): UPDATED = 2 @classmethod - def to_string(cls, integer): + def to_string(cls, integer: int) -> str: if integer == 0: return "created" if integer == 1: @@ -39,7 +39,7 @@ class CreatedByTypeEnum(Enum): NAME = 2 @classmethod - def to_string(cls, integer): + def to_string(cls, integer: int) -> str: if integer == 0: return "email" if integer == 1: @@ -67,7 +67,7 @@ class ProviderEnum(Enum): STATSIG = 4 @classmethod - def to_string(cls, integer) -> ProviderName: + def to_string(cls, integer: int) -> ProviderName: if integer == 0: return "generic" if integer == 1: diff --git a/src/sentry/integrations/github/issue_sync.py b/src/sentry/integrations/github/issue_sync.py index 52f2ec206a39..1dd6b6707097 100644 --- a/src/sentry/integrations/github/issue_sync.py +++ b/src/sentry/integrations/github/issue_sync.py @@ -9,6 +9,7 @@ from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.services.integration import integration_service from sentry.integrations.types import EXTERNAL_PROVIDERS_REVERSE, ExternalProviderEnum +from sentry.models.activity import Activity from sentry.shared_integrations.exceptions import ApiError from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service @@ -201,7 +202,7 @@ def get_resolve_sync_action(self, data: Mapping[str, Any]) -> ResolveSyncAction: return ResolveSyncAction.UNRESOLVE return ResolveSyncAction.NOOP - def create_comment_attribution(self, user_id, comment_text): + def create_comment_attribution(self, user_id: int, comment_text: str) -> str: user = user_service.get_user(user_id) username = "Unknown User" if user is None else user.name @@ -210,7 +211,9 @@ def create_comment_attribution(self, user_id, comment_text): quoted_text = "\n".join(f"> {line}" for line in comment_text.split("\n")) return f"{attribution}{quoted_text}" - def update_comment(self, issue_id, user_id, group_note): + def update_comment( + self, issue_id: str, user_id: int, group_note: Activity + ) -> dict[str, object]: quoted_comment = self.create_comment_attribution(user_id, group_note.data["text"]) repo, issue_number = issue_id.rsplit("#", 1) @@ -219,7 +222,9 @@ def update_comment(self, issue_id, user_id, group_note): repo, issue_number, group_note.data["external_id"], {"body": quoted_comment} ) - def create_comment(self, issue_id, user_id, group_note): + def create_comment( + self, issue_id: str, user_id: int, group_note: Activity + ) -> dict[str, object]: # GitHub uses markdown syntax directly without needing special formatting comment = group_note.data["text"] quoted_comment = self.create_comment_attribution(user_id, comment) diff --git a/src/sentry/integrations/msteams/integration.py b/src/sentry/integrations/msteams/integration.py index 83f66028eae2..4736953d2e34 100644 --- a/src/sentry/integrations/msteams/integration.py +++ b/src/sentry/integrations/msteams/integration.py @@ -90,7 +90,7 @@ ) -class MsTeamsIntegration(IntegrationInstallation, IntegrationNotificationClient): +class MsTeamsIntegration(IntegrationInstallation, IntegrationNotificationClient[AdaptiveCard]): def get_client(self) -> MsTeamsClient: return MsTeamsClient(self.model) @@ -124,7 +124,7 @@ class MsTeamsInstallParams(TypedDict): installation_type: str -class MsTeamsInitialDataSerializer(CamelSnakeSerializer): +class MsTeamsInitialDataSerializer(CamelSnakeSerializer[MsTeamsInstallParams]): """Initial pipeline data for Microsoft Teams installs. The Sentry bot in Teams renders a card with a single `signed_params` blob @@ -143,7 +143,7 @@ def validate(self, attrs: dict[str, Any]) -> MsTeamsInstallParams: raise serializers.ValidationError("Invalid installation link") -class MsTeamsAdvanceSerializer(CamelSnakeSerializer): +class MsTeamsAdvanceSerializer(CamelSnakeSerializer[dict[str, object]]): state = CharField(required=True) diff --git a/src/sentry/issues/endpoints/organization_issue_timeseries.py b/src/sentry/issues/endpoints/organization_issue_timeseries.py index e326e4b96b4f..30ac43c274eb 100644 --- a/src/sentry/issues/endpoints/organization_issue_timeseries.py +++ b/src/sentry/issues/endpoints/organization_issue_timeseries.py @@ -2,9 +2,9 @@ from collections.abc import Iterator from datetime import datetime, timedelta from heapq import nlargest -from typing import Any +from typing import Any, cast -from django.db.models import Count, DateTimeField, F, Func, Q +from django.db.models import Count, DateTimeField, F, Func, Q, QuerySet from django.db.models.functions import Extract from drf_spectacular.utils import extend_schema from rest_framework.exceptions import ParseError @@ -30,6 +30,7 @@ from sentry.models.group import Group, GroupStatus from sentry.models.organization import Organization from sentry.models.project import Project +from sentry.search.events.types import SnubaRow from sentry.snuba.discover import create_groupby_dict, create_result_key from sentry.utils.dates import get_rollup_from_request @@ -122,12 +123,12 @@ def get(self, request: Request, organization: Organization) -> Response: raise ParseError(f"Unknown axis: {axis}") def gen_ts( - qs, + qs: QuerySet[Group], group_by: list[str], date_column_name: str, axis: str, ) -> list[TimeSeries]: - qs = make_timeseries_query( + rows = make_timeseries_query( qs, projects, environments, @@ -142,7 +143,7 @@ def gen_ts( grouped_counter: collections.defaultdict[str, int] = collections.defaultdict(int) groupby_key_to_dict: dict[str, list[GroupBy]] = {} grouped_series: dict[str, list[Row]] = collections.defaultdict(list) - for row in qs: + for row in rows: key = create_result_key(row, group_by, {}) groupby_dict = create_groupby_dict(row, group_by, {}) for groupby_item in groupby_dict: @@ -238,7 +239,7 @@ def gen_ts( def make_timeseries_query( - qs, + qs: QuerySet[Group], projects: list[Project], environments: list[int], type_filter: Q, @@ -247,7 +248,7 @@ def make_timeseries_query( source: str, start: datetime, end: datetime, -): +) -> QuerySet[Group, SnubaRow]: environment_filter = ( Q(groupenvironment__environment_id=environments[0]) if environments else Q() ) @@ -288,7 +289,7 @@ def make_timeseries_query( .values(*values) .annotate(value=Count("id")) ) - return qs + return cast(QuerySet[Group, SnubaRow], qs) def make_timeseries_result( diff --git a/src/sentry/models/authidentity.py b/src/sentry/models/authidentity.py index e8ae3884b73e..c1b92f20d1e1 100644 --- a/src/sentry/models/authidentity.py +++ b/src/sentry/models/authidentity.py @@ -98,11 +98,11 @@ class Meta: def __str__(self) -> str: return self.ident - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, object]: return {"user_id": self.user_id, "data": self.data} - def get_display_name(self): + def get_display_name(self) -> str: return self.user.get_display_name() - def get_label(self): + def get_label(self) -> str: return self.user.get_label() diff --git a/src/sentry/models/relay.py b/src/sentry/models/relay.py index 833cfb88ef21..6135484594b4 100644 --- a/src/sentry/models/relay.py +++ b/src/sentry/models/relay.py @@ -1,6 +1,10 @@ -from typing import Any +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any from django.db import models +from django.db.models import QuerySet from django.utils import timezone from django.utils.functional import cached_property from sentry_relay.auth import PublicKey @@ -11,6 +15,9 @@ from sentry.backup.scopes import RelocationScope from sentry.db.models import Model, cell_silo_model +if TYPE_CHECKING: + from sentry.models.organization import Organization + @cell_silo_model class RelayUsage(OverwritableConfigMixin, Model): @@ -57,10 +64,10 @@ class Meta: db_table = "sentry_relay" @cached_property - def public_key_object(self): + def public_key_object(self) -> PublicKey: return PublicKey.parse(self.public_key) - def has_org_access(self, org): + def has_org_access(self, org: Organization) -> bool: # Internal relays always have access if self.is_internal: return True @@ -75,7 +82,7 @@ def has_org_access(self, org): return False @staticmethod - def for_keys(keys): + def for_keys(keys: Sequence[str]) -> QuerySet[Relay]: """ Returns all the relays that are configured with one of the specified keys """ diff --git a/src/sentry/monitors/clock_dispatch.py b/src/sentry/monitors/clock_dispatch.py index 647057dcad3c..05b878e36220 100644 --- a/src/sentry/monitors/clock_dispatch.py +++ b/src/sentry/monitors/clock_dispatch.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone from arroyo import Topic as ArroyoTopic -from arroyo.backends.kafka import KafkaPayload +from arroyo.backends.kafka import KafkaPayload, KafkaProducer from django.conf import settings from sentry_kafka_schemas.codecs import Codec from sentry_kafka_schemas.schema_types.ingest_monitors_v1 import ClockPulse @@ -35,7 +35,7 @@ def _int_or_none(s: str | None) -> int | None: return int(s) -def _get_producer(): +def _get_producer() -> KafkaProducer: return get_arroyo_producer( name="sentry.monitors.clock_dispatch", topic=Topic.MONITORS_CLOCK_TICK, @@ -124,7 +124,7 @@ def _should_hold_clock_tick(missing_partitions: frozenset[int] | None) -> bool: return not options.get("crons.clock_tick.disable_hold_on_missing_partitions") -def _dispatch_tick(ts: datetime): +def _dispatch_tick(ts: datetime) -> None: """ Dispatch a clock tick which will trigger monitor tasks. @@ -150,7 +150,7 @@ def _dispatch_tick(ts: datetime): _clock_tick_producer.produce(ArroyoTopic(topic), payload) -def try_monitor_clock_tick(ts: datetime, partition: int): +def try_monitor_clock_tick(ts: datetime, partition: int) -> None: """ Handles triggering the monitor tasks when we've rolled over the minute. diff --git a/src/sentry/replays/data_export.py b/src/sentry/replays/data_export.py index d73b461dfd10..ced623486f49 100644 --- a/src/sentry/replays/data_export.py +++ b/src/sentry/replays/data_export.py @@ -470,7 +470,7 @@ def export_replay_row_set_async( limit: int = EXPORT_QUERY_ROWS_PER_PAGE, offset: int = 0, num_pages: int = EXPORT_QUERY_PAGES_PER_TASK, -): +) -> None: """ Export all replay rows which belong to the project and exist within the range. @@ -543,7 +543,7 @@ def export_replay_project_async( limit: int, destination_bucket: str, num_pages: int = EXPORT_QUERY_PAGES_PER_TASK, -): +) -> None: """ Export every replay for a given Sentry Project ID. @@ -613,7 +613,7 @@ def export_replay_data( database_pages_per_task: int = EXPORT_QUERY_PAGES_PER_TASK, source_bucket: str = EXPORT_JOB_SOURCE_BUCKET, pubsub_topic_name: str | None = None, -): +) -> None: logger.info( "Starting replay export...", extra={ diff --git a/src/sentry/signals.py b/src/sentry/signals.py index a70422ee152d..5e78b7be3c08 100644 --- a/src/sentry/signals.py +++ b/src/sentry/signals.py @@ -3,8 +3,8 @@ import enum import functools import logging -from collections.abc import Callable -from typing import Any +from collections.abc import Callable, Hashable +from typing import Any, overload from django.dispatch.dispatcher import Signal @@ -48,14 +48,38 @@ def wrapped(*args: Any, **kwds: Any) -> Any: return wrapped - def __exit__(self, *args) -> bool | None: + def __exit__(self, *args: object) -> bool | None: global _receivers_that_raise _receivers_that_raise = self.old return None class BetterSignal(Signal): - def connect(self, receiver=None, *args, **kwargs): + @overload + def connect( + self, + receiver: None = None, + sender: object | None = None, + weak: bool = True, + dispatch_uid: Hashable | None = None, + ) -> Callable[[Callable[..., Any]], None]: ... + + @overload + def connect( + self, + receiver: Callable[..., Any], + sender: object | None = None, + weak: bool = True, + dispatch_uid: Hashable | None = None, + ) -> None: ... + + def connect( + self, + receiver: Callable[..., Any] | None = None, + sender: object | None = None, + weak: bool = True, + dispatch_uid: Hashable | None = None, + ) -> Callable[[Callable[..., Any]], None] | None: """ Support decorator syntax: @@ -65,8 +89,10 @@ def connect(self, receiver=None, *args, **kwargs): """ - def wrapped(func): - return super(BetterSignal, self).connect(func, *args, **kwargs) + def wrapped(func: Callable[..., Any]) -> None: + super(BetterSignal, self).connect( + func, sender=sender, weak=weak, dispatch_uid=dispatch_uid + ) if receiver is None: return wrapped @@ -77,7 +103,8 @@ def wrapped(func): wrapped.__module__ = receiver.__module__ if hasattr(receiver, "__doc__"): wrapped.__doc__ = receiver.__doc__ - return wrapped(receiver) + wrapped(receiver) + return None def _log_robust_failure(self, receiver: object, err: Exception) -> None: if in_test_environment(): diff --git a/src/sentry/tasks/base.py b/src/sentry/tasks/base.py index 988172abfc7f..82ede0918484 100644 --- a/src/sentry/tasks/base.py +++ b/src/sentry/tasks/base.py @@ -46,7 +46,7 @@ def instrumented_task( silenced_exceptions: tuple[type[BaseException], ...] | None = None, silo_mode: SiloMode | None = None, pass_headers: bool = False, - **kwargs, + **kwargs: object, ) -> Callable[[Callable[P, R]], Task[P, R]]: """ Decorator for defining tasks. @@ -165,8 +165,8 @@ def wrapped(func: Callable[P, R]) -> Task[P, R]: return wrapped -def track_group_async_operation(function): - def wrapper(*args, **kwargs): +def track_group_async_operation(function: Callable[P, R]) -> Callable[P, R]: + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: from sentry.utils import snuba try: diff --git a/src/sentry/tempest/tasks.py b/src/sentry/tempest/tasks.py index 5cb4d4aa4a87..eaa73d2af464 100644 --- a/src/sentry/tempest/tasks.py +++ b/src/sentry/tempest/tasks.py @@ -29,7 +29,7 @@ processing_deadline_duration=60, silo_mode=SiloMode.CELL, ) -def poll_tempest(**kwargs): +def poll_tempest(**kwargs: object) -> None: for credentials in TempestCredentials.objects.select_related("project__organization").all(): # Note: We are caching credentials in the database even if users don't have access to them. # However the latest_fetched_item_id is unique per credentials, and we do not allow PUT/PATCH @@ -61,7 +61,7 @@ def poll_tempest(**kwargs): processing_deadline_duration=60, silo_mode=SiloMode.CELL, ) -def fetch_latest_item_id(credentials_id: int, **kwargs) -> None: +def fetch_latest_item_id(credentials_id: int, **kwargs: object) -> None: # Lock duration should be slightly longer than the task deadline to prevent # overlapping tasks for the same credential. If a task is still running when # the next poll_tempest fires, the new task will skip this credential. @@ -253,7 +253,7 @@ def _fetch_latest_item_id_impl(credentials_id: int) -> None: processing_deadline_duration=60, silo_mode=SiloMode.CELL, ) -def poll_tempest_crashes(credentials_id: int, **kwargs) -> None: +def poll_tempest_crashes(credentials_id: int, **kwargs: object) -> None: # Lock duration should be slightly longer than the task deadline to prevent # overlapping tasks for the same credential. If a task is still running when # the next poll_tempest fires, the new task will skip this credential. diff --git a/src/sentry/web/frontend/auth_organization_login.py b/src/sentry/web/frontend/auth_organization_login.py index 9582149ef874..082eee24e572 100644 --- a/src/sentry/web/frontend/auth_organization_login.py +++ b/src/sentry/web/frontend/auth_organization_login.py @@ -20,7 +20,9 @@ class AuthOrganizationLoginView(AuthPageView): - def handle_sso(self, request: HttpRequest, organization: RpcOrganization, auth_provider): + def handle_sso( + self, request: HttpRequest, organization: RpcOrganization, auth_provider: AuthProvider + ) -> HttpResponseBase: helper = AuthHelper( request=request, organization=organization, @@ -44,7 +46,7 @@ def handle_sso(self, request: HttpRequest, organization: RpcOrganization, auth_p return helper.current_step() @method_decorator(never_cache) - def handle(self, request: HttpRequest, organization_slug) -> HttpResponseBase: + def handle(self, request: HttpRequest, organization_slug: str) -> HttpResponseBase: if request.method == "GET": if ( request.resolver_match diff --git a/src/social_auth/decorators.py b/src/social_auth/decorators.py index 7451425474cc..fc6c395b72d3 100644 --- a/src/social_auth/decorators.py +++ b/src/social_auth/decorators.py @@ -1,29 +1,36 @@ +from collections.abc import Callable from functools import wraps +from django.http import HttpRequest, HttpResponseBase from django.urls import reverse from social_auth.backends import get_backend from social_auth.exceptions import WrongBackend -def dsa_view(redirect_name=None): +def dsa_view( + redirect_name: str | None = None, +) -> Callable[[Callable[..., HttpResponseBase]], Callable[..., HttpResponseBase]]: """Decorate djangos-social-auth views. Will check and retrieve backend or return HttpResponseServerError if backend is not found. redirect_name parameter is used to build redirect URL used by backend. """ - def dec(func): + def dec(func: Callable[..., HttpResponseBase]) -> Callable[..., HttpResponseBase]: @wraps(func) - def wrapper(request, backend, *args, **kwargs): + def wrapper( + request: HttpRequest, backend: str, *args: object, **kwargs: object + ) -> HttpResponseBase: if redirect_name: redirect = reverse(redirect_name, args=(backend,)) else: redirect = request.path - request.social_auth_backend = get_backend(backend, request, redirect) - if request.social_auth_backend is None: + social_auth_backend = get_backend(backend, request, redirect) + setattr(request, "social_auth_backend", social_auth_backend) + if social_auth_backend is None: raise WrongBackend(backend) - return func(request, request.social_auth_backend, *args, **kwargs) + return func(request, social_auth_backend, *args, **kwargs) return wrapper From 8ce132fd58de90d0cedd5e313e4d2d61859c302a Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:17:31 -0700 Subject: [PATCH 033/103] ref(typing): graduate lane 5 weaklist modules Add precise annotations to the lane 5 modules so they pass strict mypy without their weaklist entries. Widen the source-code search installation_class contract to accept a tuple of provider classes, and align the group seen-stats query result and status details with the shapes they already produce at runtime. --- src/sentry/api/endpoints/assistant.py | 6 +- ...anization_onboarding_continuation_email.py | 19 ++++- .../api/endpoints/organization_sessions.py | 7 +- .../api/endpoints/project_tagkey_details.py | 26 ++++--- src/sentry/api/serializers/models/group.py | 77 ++++++++++++------- .../api/serializers/models/group_stream.py | 9 ++- .../rest_framework/groupsearchview.py | 6 +- src/sentry/identity/base.py | 9 ++- src/sentry/integrations/github/search.py | 12 ++- .../source_code_management/search.py | 4 +- src/sentry/integrations/vsts/search.py | 6 +- .../project_performance_issue_settings.py | 15 +++- src/sentry/models/dashboard.py | 7 +- src/sentry/models/releaseenvironment.py | 37 +++++++-- src/sentry/monitors/types.py | 6 +- src/sentry/replays/lib/storage.py | 8 +- src/sentry/snuba/issue_platform.py | 49 ++++++------ src/sentry/tasks/options.py | 6 +- src/sentry/testutils/helpers/options.py | 13 +++- src/sentry/web/frontend/openidtoken.py | 24 +++--- src/social_auth/exceptions.py | 6 +- 21 files changed, 224 insertions(+), 128 deletions(-) diff --git a/src/sentry/api/endpoints/assistant.py b/src/sentry/api/endpoints/assistant.py index b774be068900..075f192960bf 100644 --- a/src/sentry/api/endpoints/assistant.py +++ b/src/sentry/api/endpoints/assistant.py @@ -29,13 +29,13 @@ class AssistantSerializer(serializers.Serializer): status = serializers.ChoiceField(choices=list(zip(VALID_STATUSES, VALID_STATUSES))) useful = serializers.BooleanField(required=False) - def validate_guide_id(self, value): + def validate_guide_id(self, value: int) -> int: valid_ids = manager.get_valid_ids() if value not in valid_ids: raise serializers.ValidationError("Not a valid assistant guide_id") return value - def validate(self, attrs): + def validate(self, attrs: dict[str, object]) -> dict[str, object]: attrs = super().validate(attrs) guide = attrs.get("guide") guide_id = attrs.get("guide_id") @@ -78,7 +78,7 @@ def get(self, request: Request) -> Response: [{"guide": key, "seen": id in seen_ids} for key, id in manager.all().items()] ) - def put(self, request: Request): + def put(self, request: Request) -> Response | HttpResponse: """Mark a guide as viewed or dismissed. Request is of the form { diff --git a/src/sentry/api/endpoints/organization_onboarding_continuation_email.py b/src/sentry/api/endpoints/organization_onboarding_continuation_email.py index 8351444da7ce..0c50698ffcec 100644 --- a/src/sentry/api/endpoints/organization_onboarding_continuation_email.py +++ b/src/sentry/api/endpoints/organization_onboarding_continuation_email.py @@ -1,6 +1,9 @@ +from typing import TypedDict + from django.contrib.auth.models import AnonymousUser from rest_framework import serializers from rest_framework.request import Request +from rest_framework.response import Response from sentry import analytics from sentry.analytics.events.onboarding_continuation_sent import OnboardingContinuationSent @@ -15,13 +18,23 @@ from sentry.utils.strings import oxfordize_list -class OnboardingContinuationSerializer(CamelSnakeSerializer): +class OnboardingContinuationSerializer(CamelSnakeSerializer[dict[str, list[str]]]): platforms = serializers.ListField( child=serializers.CharField(max_length=255), ) -def get_request_builder_args(user: User, organization: Organization, platforms: list[str]): +class RequestBuilderArgs(TypedDict): + subject: str + type: str + context: dict[str, object] + template: str + html_template: str + + +def get_request_builder_args( + user: User, organization: Organization, platforms: list[str] +) -> RequestBuilderArgs: num_platforms = len(platforms) context = { "recipient_name": user.get_display_name(), @@ -53,7 +66,7 @@ class OrganizationOnboardingContinuationEmail(OrganizationEndpoint): owner = ApiOwner.VALUE_DISCOVERY permission_classes = (OnboardingContinuationPermission,) - def post(self, request: Request, organization: Organization): + def post(self, request: Request, organization: Organization) -> Response: serializer = OnboardingContinuationSerializer(data=request.data) if not serializer.is_valid(): return self.respond(serializer.errors, status=400) diff --git a/src/sentry/api/endpoints/organization_sessions.py b/src/sentry/api/endpoints/organization_sessions.py index d0be691bb770..ab6da2829a9b 100644 --- a/src/sentry/api/endpoints/organization_sessions.py +++ b/src/sentry/api/endpoints/organization_sessions.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from contextlib import contextmanager from drf_spectacular.utils import extend_schema @@ -113,7 +114,7 @@ def build_sessions_query( organization: Organization, offset: int | None, limit: int | None, - ): + ) -> QueryDefinition: try: params = self.get_filter_params(request, organization, date_filter_optional=True) except NoProjects: @@ -127,7 +128,7 @@ def build_sessions_query( ) @contextmanager - def handle_query_errors(self): + def handle_query_errors(self) -> Iterator[None]: try: with handle_query_errors(): yield @@ -136,7 +137,7 @@ def handle_query_errors(self): class SessionsDataSeriesPaginator(GenericOffsetPaginator): - def get_result(self, limit, cursor=None): + def get_result(self, limit: int, cursor: Cursor | None = None) -> CursorResult[object]: assert limit > 0 offset = cursor.offset if cursor is not None else 0 data = self.data_fn(offset=offset, limit=limit + 1) diff --git a/src/sentry/api/endpoints/project_tagkey_details.py b/src/sentry/api/endpoints/project_tagkey_details.py index 062b4dfc78c3..7066a12248a0 100644 --- a/src/sentry/api/endpoints/project_tagkey_details.py +++ b/src/sentry/api/endpoints/project_tagkey_details.py @@ -1,3 +1,5 @@ +from typing import cast + from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.request import Request from rest_framework.response import Response @@ -20,8 +22,9 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import PROTECTED_TAG_KEYS from sentry.models.environment import Environment +from sentry.models.project import Project from sentry.ratelimits.config import RateLimitConfig -from sentry.tagstore.types import TagKeySerializer, TagKeySerializerResponse +from sentry.tagstore.types import TagKey, TagKeySerializer, TagKeySerializerResponse from sentry.types.ratelimit import RateLimit, RateLimitCategory _TAG_KEY_PARAM = OpenApiParameter( @@ -68,7 +71,9 @@ class ProjectTagKeyDetailsEndpoint(ProjectEndpoint): 404: RESPONSE_NOT_FOUND, }, ) - def get(self, request: Request, project, key) -> Response[TagKeySerializerResponse]: + def get( + self, request: Request, project: Project, key: str + ) -> Response[TagKeySerializerResponse]: """ Return details about a tag key, including the number of unique and total values. """ @@ -102,7 +107,7 @@ def get(self, request: Request, project, key) -> Response[TagKeySerializerRespon 404: RESPONSE_NOT_FOUND, }, ) - def delete(self, request: Request, project, key) -> Response: + def delete(self, request: Request, project: Project, key: str) -> Response: """ Remove all occurrences of the given tag key and its values. """ @@ -138,14 +143,17 @@ def delete(self, request: Request, project, key) -> Response: return Response(status=204) - def get_tag_keys_for_deletion(self, project, key): + def get_tag_keys_for_deletion(self, project: Project, key: str) -> list[TagKey]: try: return [ - tagstore.backend.get_tag_key( - project_id=project.id, - key=key, - environment_id=None, - tenant_ids={"organization_id": project.organization_id}, + cast( + TagKey, + tagstore.backend.get_tag_key( + project_id=project.id, + key=key, + environment_id=None, + tenant_ids={"organization_id": project.organization_id}, + ), ) ] except tagstore.TagKeyNotFound: diff --git a/src/sentry/api/serializers/models/group.py b/src/sentry/api/serializers/models/group.py index c7d324f70c87..29a6bf8d26d5 100644 --- a/src/sentry/api/serializers/models/group.py +++ b/src/sentry/api/serializers/models/group.py @@ -6,7 +6,16 @@ from collections import defaultdict from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, TypeGuard, cast +from typing import ( + TYPE_CHECKING, + Any, + Literal, + NotRequired, + Protocol, + TypedDict, + TypeGuard, + cast, +) from django.conf import settings from django.contrib.auth.models import AnonymousUser @@ -14,6 +23,7 @@ from sentry_sdk import traces from sentry import tagstore +from sentry.api.event_search import SearchFilter from sentry.api.serializers import Serializer, register, serialize from sentry.api.serializers.models.actor import ActorSerializer, ActorSerializerResponse from sentry.auth.principal import has_user @@ -52,7 +62,11 @@ from sentry.notifications.types import NotificationSettingEnum from sentry.reprocessing2 import get_progress from sentry.search.events.constants import RELEASE_STAGE_ALIAS -from sentry.search.events.filter import convert_search_filter_to_snuba_query, format_search_filter +from sentry.search.events.filter import ( + FilterConvertParams, + convert_search_filter_to_snuba_query, + format_search_filter, +) from sentry.services.eventstore.reprocessing.base import ReprocessingInfo from sentry.snuba.dataset import Dataset from sentry.tagstore.snuba.backend import fix_tag_value_data @@ -184,6 +198,12 @@ class SeenStats(TypedDict): first_seen: datetime | None last_seen: datetime | None user_count: int + filtered: NotRequired[SeenStats | None] + lifetime: NotRequired[SeenStats | None] + + +class _SeenStatsQueryResult(TypedDict): + data: Sequence[Mapping[str, object]] class SeenStatsResponse(TypedDict): @@ -496,9 +516,11 @@ def _collapse(self, key: str) -> bool: return False return key in self.collapse - def _get_status(self, attrs: Mapping[str, Any], obj: Group): + def _get_status( + self, attrs: Mapping[str, Any], obj: Group + ) -> tuple[GroupStatusDetailsResponseOptional, GroupStatusStr]: status = obj.status - status_details = {} + status_details: GroupStatusDetailsResponseOptional = {} if attrs["ignore_until"]: snooze = attrs["ignore_until"] if snooze.is_valid(group=obj): @@ -1028,12 +1050,12 @@ def __init__( environment_ids: list[int] | None = None, start: datetime | None = None, end: datetime | None = None, - search_filters=None, - collapse=None, - expand=None, - organization_id=None, - project_ids=None, - ): + search_filters: Sequence[SearchFilter] | None = None, + collapse: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + organization_id: int | None = None, + project_ids: list[int] | None = None, + ) -> None: super().__init__(collapse=collapse, expand=expand) from sentry.search.snuba.executors import get_search_filter @@ -1070,15 +1092,19 @@ def __init__( conditions = [] if search_filters is not None: + params = cast( + FilterConvertParams, + { + "organization_id": organization_id, + "project_id": project_ids, + "environment_id": environment_ids, + }, + ) for search_filter in search_filters: if search_filter.key.name not in self.skip_snuba_fields: formatted_conditions, projects_to_filter, group_ids = format_search_filter( search_filter, - params={ - "organization_id": organization_id, - "project_id": project_ids, - "environment_id": environment_ids, - }, + params=params, ) # if no re-formatted conditions, use fallback method @@ -1088,11 +1114,7 @@ def __init__( elif group_ids: new_condition = convert_search_filter_to_snuba_query( search_filter, - params={ - "organization_id": organization_id, - "project_id": project_ids, - "environment_id": environment_ids, - }, + params=params, ) if new_condition: @@ -1138,7 +1160,7 @@ def _execute_error_seen_stats_query( end: datetime | None = None, conditions: Sequence[Sequence[object]] | None = None, environment_ids: list[int] | None = None, - ) -> dict[str, object]: + ) -> _SeenStatsQueryResult: project_ids = list({item.project_id for item in item_list}) group_ids = [item.id for item in item_list] aggregations = [ @@ -1153,7 +1175,7 @@ def _execute_error_seen_stats_query( filters["environment"] = environment_ids return cast( - dict[str, object], + _SeenStatsQueryResult, aliased_query( dataset=Dataset.Events, start=start, @@ -1177,7 +1199,7 @@ def _execute_generic_seen_stats_query( end: datetime | None = None, conditions: Sequence[Sequence[object]] | None = None, environment_ids: list[int] | None = None, - ) -> dict[str, object]: + ) -> _SeenStatsQueryResult: project_ids = list({item.project_id for item in item_list}) group_ids = [item.id for item in item_list] aggregations = [ @@ -1191,7 +1213,7 @@ def _execute_generic_seen_stats_query( filters["environment"] = environment_ids return cast( - dict[str, object], + _SeenStatsQueryResult, aliased_query( dataset=Dataset.IssuePlatform, start=start, @@ -1210,8 +1232,11 @@ def _execute_generic_seen_stats_query( @staticmethod def _parse_seen_stats_results( - result, item_list, use_result_first_seen_times_seen, environment_ids=None - ): + result: _SeenStatsQueryResult, + item_list: Sequence[Group], + use_result_first_seen_times_seen: bool, + environment_ids: list[int] | None = None, + ) -> Mapping[Group, SeenStats]: seen_data = { issue["group_id"]: fix_tag_value_data( dict(filter(lambda key: key[0] != "group_id", issue.items())) diff --git a/src/sentry/api/serializers/models/group_stream.py b/src/sentry/api/serializers/models/group_stream.py index b4afaee39a37..a1bfccfa691d 100644 --- a/src/sentry/api/serializers/models/group_stream.py +++ b/src/sentry/api/serializers/models/group_stream.py @@ -24,6 +24,7 @@ GroupStatusDetailsResponseOptional, GroupStatusStr, SeenStats, + _SeenStatsQueryResult, is_seen_stats, snuba_tsdb, ) @@ -244,7 +245,7 @@ def __call__( end: datetime | None = None, conditions=None, environment_ids: list[int] | None = None, - ) -> Mapping[str, Any]: ... + ) -> _SeenStatsQueryResult: ... class _Filtered(TypedDict): @@ -606,7 +607,7 @@ def query_tsdb( get_range = functools.partial( snuba_tsdb.get_range, environment_ids=environment_ids, - tenant_ids={"organization_id": self.organization_id}, + tenant_ids={"organization_id": cast(int, self.organization_id)}, project_ids=self.project_ids, **query_params, ) @@ -652,14 +653,14 @@ def __seen_stats_impl( time_range_result = self._parse_seen_stats_results( partial_execute_seen_stats_query(), error_issue_list, - self.start or self.end or self.conditions, + bool(self.start or self.end or self.conditions), self.environment_ids, ) filtered_result = ( self._parse_seen_stats_results( partial_execute_seen_stats_query(conditions=self.conditions), error_issue_list, - self.start or self.end or self.conditions, + bool(self.start or self.end or self.conditions), self.environment_ids, ) if self.conditions and not self._collapse("filtered") diff --git a/src/sentry/api/serializers/rest_framework/groupsearchview.py b/src/sentry/api/serializers/rest_framework/groupsearchview.py index e5bfeb9ecfb5..3ff830d41ad8 100644 --- a/src/sentry/api/serializers/rest_framework/groupsearchview.py +++ b/src/sentry/api/serializers/rest_framework/groupsearchview.py @@ -108,7 +108,7 @@ def validate_query(self, value: str) -> str: raise ValidationError(detail=f"Invalid issue search query: {e}") return value - def validate_projects(self, value): + def validate_projects(self, value: list[int]) -> list[int]: if value != [-1]: project_ids = set(value) existing_project_ids = set( @@ -123,7 +123,7 @@ def validate_projects(self, value): return value - def validate(self, data) -> GroupSearchViewValidatorResponse: + def validate(self, data: GroupSearchViewValidatorResponse) -> GroupSearchViewValidatorResponse: if data["projects"] == [-1]: data["projects"] = [] data["isAllProjects"] = True @@ -142,5 +142,5 @@ class GroupSearchViewPostValidator(ViewValidator): required=False, help_text="Whether to star the issue view for the current user." ) - def validate(self, data): + def validate(self, data: GroupSearchViewValidatorResponse) -> GroupSearchViewValidatorResponse: return super().validate(data) diff --git a/src/sentry/identity/base.py b/src/sentry/identity/base.py index a3d4e39c32ce..bf24578ffba8 100644 --- a/src/sentry/identity/base.py +++ b/src/sentry/identity/base.py @@ -2,6 +2,7 @@ import abc import logging +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from sentry.identity.services.identity.model import RpcIdentity @@ -20,7 +21,7 @@ class Provider(PipelineProvider["IdentityPipeline"], abc.ABC): auto_create_provider_model = False create_organization_identity = False - def __init__(self, **config): + def __init__(self, **config: object) -> None: super().__init__() self.config = config self.logger = logging.getLogger(f"sentry.identity.{self.key}") @@ -33,7 +34,7 @@ def get_pipeline_config(self, data: dict[str, Any]) -> dict[str, str]: """ return {} - def build_identity(self, state): + def build_identity(self, state: Mapping[str, object]) -> Mapping[str, object]: """ Return a mapping containing the identity information. @@ -58,7 +59,9 @@ def build_identity(self, state): """ raise NotImplementedError - def update_identity(self, new_data, current_data): + def update_identity( + self, new_data: Mapping[str, object], current_data: Mapping[str, object] + ) -> Mapping[str, object]: """ When re-authenticating with a provider, the identity data may need to be mutated based on the previous state. An example of this is Google, diff --git a/src/sentry/integrations/github/search.py b/src/sentry/integrations/github/search.py index 05f672f9edfa..5f646592b2bd 100644 --- a/src/sentry/integrations/github/search.py +++ b/src/sentry/integrations/github/search.py @@ -21,7 +21,9 @@ @control_silo_endpoint -class GithubSharedSearchEndpoint(SourceCodeSearchEndpoint): +class GithubSharedSearchEndpoint( + SourceCodeSearchEndpoint[GitHubIntegration | GitHubEnterpriseIntegration] +): """NOTE: This endpoint is a shared search endpoint for Github and Github Enterprise integrations.""" @property @@ -29,11 +31,15 @@ def repository_field(self) -> str: return "repo" @property - def integration_provider(self): + def integration_provider(self) -> str | None: return None @property - def installation_class(self): + def installation_class( + self, + ) -> ( + type[GitHubIntegration] | tuple[type[GitHubIntegration], type[GitHubEnterpriseIntegration]] + ): return (GitHubIntegration, GitHubEnterpriseIntegration) def handle_search_issues(self, installation: T, query: str, repo: str | None) -> Response: diff --git a/src/sentry/integrations/source_code_management/search.py b/src/sentry/integrations/source_code_management/search.py index 4a66a92a8a26..a9c4f19d500b 100644 --- a/src/sentry/integrations/source_code_management/search.py +++ b/src/sentry/integrations/source_code_management/search.py @@ -52,9 +52,7 @@ def integration_provider(self) -> str | None: @property @abstractmethod - def installation_class( - self, - ) -> type[T]: + def installation_class(self) -> type[T] | tuple[type[T], ...]: raise NotImplementedError @abstractmethod diff --git a/src/sentry/integrations/vsts/search.py b/src/sentry/integrations/vsts/search.py index ed870b86b1ea..11373fe052ae 100644 --- a/src/sentry/integrations/vsts/search.py +++ b/src/sentry/integrations/vsts/search.py @@ -13,13 +13,13 @@ @control_silo_endpoint -class VstsSearchEndpoint(SourceCodeSearchEndpoint): +class VstsSearchEndpoint(SourceCodeSearchEndpoint[VstsIntegration]): @property - def integration_provider(self): + def integration_provider(self) -> str: return IntegrationProviderSlug.AZURE_DEVOPS.value @property - def installation_class(self): + def installation_class(self) -> type[VstsIntegration]: return VstsIntegration def handle_search_issues(self, installation: T, query: str, repo: str | None) -> Response: diff --git a/src/sentry/issues/endpoints/project_performance_issue_settings.py b/src/sentry/issues/endpoints/project_performance_issue_settings.py index 8600bb40623d..e54f687f1c01 100644 --- a/src/sentry/issues/endpoints/project_performance_issue_settings.py +++ b/src/sentry/issues/endpoints/project_performance_issue_settings.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable, Mapping from enum import Enum from typing import Any @@ -236,7 +237,9 @@ def get_management_options() -> list[str]: ] -def get_disabled_threshold_options(payload, current_settings): +def get_disabled_threshold_options( + payload: Iterable[str], current_settings: Mapping[str, object] +) -> list[str]: """ Returns the option keys that are disabled, based on the current management settings. """ @@ -245,7 +248,11 @@ def get_disabled_threshold_options(payload, current_settings): for option in payload: if option not in management_options: manage_detector_setting = thresholds_to_manage_map.get(option) - is_threshold_enabled = current_settings.get(manage_detector_setting) + is_threshold_enabled = ( + current_settings.get(manage_detector_setting) + if manage_detector_setting is not None + else None + ) if not is_threshold_enabled: options.append(option) return options @@ -279,12 +286,12 @@ class ProjectPerformanceIssueSettingsEndpoint(ProjectEndpoint): } permission_classes = (ProjectSettingPermission,) - def has_feature(self, project, request) -> bool: + def has_feature(self, project: Project, request: Request) -> bool: return features.has( "organizations:performance-view", project.organization, actor=request.user ) - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project: Project) -> Response: """ Retrieve performance issue settings `````````````````` diff --git a/src/sentry/models/dashboard.py b/src/sentry/models/dashboard.py index 5c845efabaa9..2ade9048faea 100644 --- a/src/sentry/models/dashboard.py +++ b/src/sentry/models/dashboard.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from collections.abc import Iterable from typing import Any, ClassVar import sentry_sdk @@ -75,7 +76,7 @@ def get_favorite_dashboard( def reorder_favorite_dashboards( self, organization: Organization, user_id: int, new_dashboard_positions: list[int] - ): + ) -> None: """ Reorders the positions of favorited dashboards for a user in an organization. Does NOT add or remove favorited dashboards. Also normalizes the positions @@ -423,7 +424,7 @@ class Meta: __repr__ = sane_repr("organization", "title") @property - def favorited_by(self): + def favorited_by(self) -> Iterable[int]: """ @deprecated Use the DashboardFavoriteUser object manager instead. """ @@ -433,7 +434,7 @@ def favorited_by(self): return user_ids @favorited_by.setter - def favorited_by(self, user_ids): + def favorited_by(self, user_ids: Iterable[int]) -> None: """ @deprecated Use the DashboardFavoriteUser object manager instead. """ diff --git a/src/sentry/models/releaseenvironment.py b/src/sentry/models/releaseenvironment.py index 1c16b865f1fb..53b4516f459e 100644 --- a/src/sentry/models/releaseenvironment.py +++ b/src/sentry/models/releaseenvironment.py @@ -1,3 +1,9 @@ +from __future__ import annotations + +from collections.abc import MutableMapping +from datetime import datetime +from typing import TYPE_CHECKING, cast + from django.db import models from django.utils import timezone @@ -14,6 +20,11 @@ from sentry.utils.cache import cache from sentry.utils.last_seen import BumpResult, try_bump_last_seen +if TYPE_CHECKING: + from sentry.models.environment import Environment + from sentry.models.project import Project + from sentry.models.release import Release + @cell_silo_model class ReleaseEnvironment(Model): @@ -35,19 +46,35 @@ class Meta: __repr__ = sane_repr("organization_id", "release_id", "environment_id") @classmethod - def get_cache_key(cls, organization_id, release_id, environment_id) -> str: + def get_cache_key(cls, organization_id: int, release_id: int, environment_id: int) -> str: return f"releaseenv:2:{organization_id}:{release_id}:{environment_id}" @classmethod - def get_or_create(cls, project, release, environment, datetime, metrics_tags=None, **kwargs): + def get_or_create( + cls, + project: Project, + release: Release, + environment: Environment, + datetime: datetime, + metrics_tags: MutableMapping[str, str] | None = None, + **kwargs: object, + ) -> ReleaseEnvironment: with metrics.timer("models.releaseenvironment.get_or_create") as timer_tags: - instance = cls._get_or_create_impl(project, release, environment, datetime, timer_tags) + tags = cast(dict[str, str], timer_tags) + instance = cls._get_or_create_impl(project, release, environment, datetime, tags) if metrics_tags is not None: - metrics_tags.update(timer_tags) + metrics_tags.update(tags) return instance @classmethod - def _get_or_create_impl(cls, project, release, environment, datetime, metric_tags): + def _get_or_create_impl( + cls, + project: Project, + release: Release, + environment: Environment, + datetime: datetime, + metric_tags: dict[str, str], + ) -> ReleaseEnvironment: cache_key = cls.get_cache_key(project.id, release.id, environment.id) instance = cache.get(cache_key) diff --git a/src/sentry/monitors/types.py b/src/sentry/monitors/types.py index 3729f4c1e873..cb966f099bf0 100644 --- a/src/sentry/monitors/types.py +++ b/src/sentry/monitors/types.py @@ -27,7 +27,7 @@ class CheckinPayload(TypedDict): status: str environment: NotRequired[str] duration: NotRequired[int] - monitor_config: NotRequired[dict] + monitor_config: NotRequired[dict[str, object]] contexts: NotRequired[CheckinContexts] @@ -78,11 +78,11 @@ class CheckinItem: """ @cached_property - def valid_monitor_slug(self): + def valid_monitor_slug(self) -> str: return slugify_monitor_slug(self.payload["monitor_slug"]) @property - def processing_key(self): + def processing_key(self) -> str: """ This key is used to uniquely identify the check-in group this check-in belongs to. Check-ins grouped together will never be processed in diff --git a/src/sentry/replays/lib/storage.py b/src/sentry/replays/lib/storage.py index 9193524290af..2976108e45af 100644 --- a/src/sentry/replays/lib/storage.py +++ b/src/sentry/replays/lib/storage.py @@ -172,8 +172,8 @@ def get(self, segment: RecordingSegmentStorageMeta) -> bytes | None: def set(self, segment: RecordingSegmentStorageMeta, value: bytes) -> None: return storage_kv.set(self.make_key(segment), value) - def initialize_client(self): - return storage_kv.initialize_client() + def initialize_client(self) -> None: + storage_kv.initialize_client() def make_key(self, segment: RecordingSegmentStorageMeta) -> str: return _make_recording_filename( @@ -217,7 +217,7 @@ def delete(self, key: str) -> None: with measure_storage_operation("delete", "replays"): storage.delete(key) - def initialize_client(self): + def initialize_client(self) -> None: storage = get_storage(self._make_storage_options()) # acccess the storage client so it is initialized below. # this will prevent race condition parallel credential getting during segment download @@ -226,7 +226,7 @@ def initialize_client(self): if hasattr(storage, "client"): storage.client - def _make_storage_options(self) -> dict | None: + def _make_storage_options(self) -> dict[str, object] | None: backend = options.get("replay.storage.backend") if backend: return {"backend": backend, "options": options.get("replay.storage.options")} diff --git a/src/sentry/snuba/issue_platform.py b/src/sentry/snuba/issue_platform.py index 5ca6f4f79c9c..19b9314f2fb2 100644 --- a/src/sentry/snuba/issue_platform.py +++ b/src/sentry/snuba/issue_platform.py @@ -3,6 +3,7 @@ from datetime import timedelta from sentry_sdk import traces +from snuba_sdk import Condition from sentry.discover.arithmetic import categorize_columns from sentry.exceptions import InvalidSearchQuery @@ -17,27 +18,27 @@ def query( - selected_columns, - query, - snuba_params, - equations=None, - orderby=None, - offset=None, - limit=50, - auto_fields=False, - auto_aggregations=False, - include_equation_fields=False, - allow_metric_aggregates=False, - use_aggregate_conditions=False, - conditions=None, - functions_acl=None, - transform_alias_to_input_format=False, - sample=None, - has_metrics=False, - skip_tag_resolution=False, - on_demand_metrics_enabled=False, + selected_columns: list[str], + query: str, + snuba_params: SnubaParams, + equations: list[str] | None = None, + orderby: list[str] | None = None, + offset: int | None = None, + limit: int = 50, + auto_fields: bool = False, + auto_aggregations: bool = False, + include_equation_fields: bool = False, + allow_metric_aggregates: bool = False, + use_aggregate_conditions: bool = False, + conditions: list[Condition] | None = None, + functions_acl: list[str] | None = None, + transform_alias_to_input_format: bool = False, + sample: float | None = None, + has_metrics: bool = False, + skip_tag_resolution: bool = False, + on_demand_metrics_enabled: bool = False, on_demand_metrics_type: MetricSpecType | None = None, - fallback_to_transactions=False, + fallback_to_transactions: bool = False, query_source: QuerySource | None = None, *, referrer: str, @@ -115,16 +116,16 @@ def timeseries_query( zerofill_results: bool = True, comparison_delta: timedelta | None = None, functions_acl: list[str] | None = None, - allow_metric_aggregates=False, - has_metrics=False, - on_demand_metrics_enabled=False, + allow_metric_aggregates: bool = False, + has_metrics: bool = False, + on_demand_metrics_enabled: bool = False, on_demand_metrics_type: MetricSpecType | None = None, query_source: QuerySource | None = None, fallback_to_transactions: bool = False, transform_alias_to_input_format: bool = False, *, referrer: str, -): +) -> SnubaTSResult: """ High-level API for doing arbitrary user timeseries queries against events. diff --git a/src/sentry/tasks/options.py b/src/sentry/tasks/options.py index dfc0b71cbb0f..2e98e1f90c4f 100644 --- a/src/sentry/tasks/options.py +++ b/src/sentry/tasks/options.py @@ -18,7 +18,7 @@ namespace=options_control_tasks, silo_mode=SiloMode.CONTROL, ) -def sync_options_control(cutoff=ONE_HOUR): +def sync_options_control(cutoff: int = ONE_HOUR) -> None: _sync_options(cutoff) @@ -26,11 +26,11 @@ def sync_options_control(cutoff=ONE_HOUR): name="sentry.tasks.options.sync_options", namespace=options_tasks, ) -def sync_options(cutoff=ONE_HOUR): +def sync_options(cutoff: int = ONE_HOUR) -> None: _sync_options(cutoff) -def _sync_options(cutoff): +def _sync_options(cutoff: int) -> None: """ Ensures all options that have been updated (within the database) since ``cutoff`` have their correct values stored in the cache. diff --git a/src/sentry/testutils/helpers/options.py b/src/sentry/testutils/helpers/options.py index 31a5d6f4d62d..e56d2d548f0c 100644 --- a/src/sentry/testutils/helpers/options.py +++ b/src/sentry/testutils/helpers/options.py @@ -1,15 +1,20 @@ __all__ = ["override_options"] +from collections.abc import Iterator, Mapping from contextlib import contextmanager +from typing import TYPE_CHECKING from unittest.mock import patch from django.test.utils import override_settings from sentry.utils.types import Any +if TYPE_CHECKING: + from sentry.options.store import Key + @contextmanager -def override_options(options): +def override_options(options: Mapping[str, object]) -> Iterator[None]: """ A context manager for overriding specific configuration Options. @@ -22,13 +27,13 @@ def override_options(options): wrapped = default_manager.store.get original_lookup = OptionsManager.lookup_key - def new_get(key, **kwargs): + def new_get(key: Key, silent: bool = False) -> object | None: try: return options[key.name] except KeyError: - return wrapped(key, **kwargs) + return wrapped(key, silent=silent) - def new_lookup(self: OptionsManager, key: str): + def new_lookup(self: OptionsManager, key: str) -> Key: # use the default key definition if available if key not in options or key in self.registry: return original_lookup(self, key) diff --git a/src/sentry/web/frontend/openidtoken.py b/src/sentry/web/frontend/openidtoken.py index d39497694aa1..f9b071c72cbb 100644 --- a/src/sentry/web/frontend/openidtoken.py +++ b/src/sentry/web/frontend/openidtoken.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, timedelta from typing import Protocol from django.utils import timezone @@ -10,7 +10,7 @@ DEFAULT_EXPIRATION = timedelta(minutes=10) -def default_expiration(): +def default_expiration() -> datetime: return timezone.now() + DEFAULT_EXPIRATION @@ -34,14 +34,14 @@ class OpenIDToken: def __init__( self, - aud, - sub, - shared_secret, - iss="https://sentry.io", - exp=None, - iat=None, - nonce=None, - ): + aud: str, + sub: int | str, + shared_secret: str, + iss: str = "https://sentry.io", + exp: datetime | None = None, + iat: datetime | None = None, + nonce: str | None = None, + ) -> None: self.shared_secret = shared_secret self.aud = aud self.sub = str(sub) # Convert to string per OpenID Connect spec @@ -68,8 +68,8 @@ def get_signed_id_token(self, grant: GrantLike) -> str: claims["nonce"] = self.nonce return jwt_utils.encode(claims, self.shared_secret, headers={**headers, "alg": "HS256"}) - def _get_user_details(self, grant: GrantLike) -> dict: - user_details = {} + def _get_user_details(self, grant: GrantLike) -> dict[str, object]: + user_details: dict[str, object] = {} if grant.has_scope("profile"): profile_details = { "name": grant.user.name, diff --git a/src/social_auth/exceptions.py b/src/social_auth/exceptions.py index 63d8a72f8dc2..050d1982283d 100644 --- a/src/social_auth/exceptions.py +++ b/src/social_auth/exceptions.py @@ -11,7 +11,7 @@ def __str__(self) -> str: class WrongBackend(BackendError): - def __init__(self, backend_name): + def __init__(self, backend_name: str) -> None: self.backend_name = backend_name def __str__(self) -> str: @@ -30,7 +30,7 @@ def __str__(self) -> str: class AuthException(SocialAuthBaseException): """Auth process exception.""" - def __init__(self, backend, *args, **kwargs): + def __init__(self, backend: object, *args: object, **kwargs: object) -> None: self.backend = backend super().__init__(*args, **kwargs) @@ -71,7 +71,7 @@ def __str__(self) -> str: class AuthMissingParameter(AuthException): """Missing parameter needed to start or complete the process.""" - def __init__(self, backend, parameter, *args, **kwargs): + def __init__(self, backend: object, parameter: str, *args: object, **kwargs: object) -> None: self.parameter = parameter super().__init__(backend, *args, **kwargs) From 3fd4903e8a2f3ea13f615afadff93b7eddcfddbb Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:31:10 -0700 Subject: [PATCH 034/103] ref(typing): remove lane 4 and 5 weaklist entries Integrate the two lane batches and remove their 38 entries. Fix the identity, signal, release-environment, and search-endpoint contracts exposed by the stricter signatures, including the runtime generic base that the endpoint decorator erases. --- pyproject.toml | 38 ------------------- src/sentry/event_manager.py | 2 +- src/sentry/flags/endpoints/logs.py | 11 ++++-- src/sentry/identity/datadog/provider.py | 21 +++++----- src/sentry/identity/gcp/provider.py | 9 +++-- src/sentry/identity/pipeline.py | 7 +++- src/sentry/integrations/github/search.py | 11 ++++-- src/sentry/integrations/vsts/search.py | 9 ++++- src/sentry/models/releaseenvironment.py | 10 ++--- src/sentry/signals.py | 27 +++++++------ src/sentry/testutils/helpers/options.py | 2 + src/sentry/users/models/identity.py | 10 ++--- .../consumers/test_schema_validator.py | 4 +- 13 files changed, 74 insertions(+), 87 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8d31f84585a8..a011f479dad8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -455,23 +455,17 @@ module = [ "sentry.api.base", "sentry.api.client", "sentry.api.endpoints.accept_organization_invite", - "sentry.api.endpoints.artifact_bundles", - "sentry.api.endpoints.assistant", "sentry.api.endpoints.auth_config", "sentry.api.endpoints.auth_index", "sentry.api.endpoints.broadcast_index", "sentry.api.endpoints.builtin_symbol_sources", "sentry.api.endpoints.chunk", "sentry.api.endpoints.debug_files", - "sentry.api.endpoints.event_attachment_details", "sentry.api.endpoints.organization_events", "sentry.api.endpoints.organization_events_stats", "sentry.api.endpoints.organization_missing_org_members", - "sentry.api.endpoints.organization_onboarding_continuation_email", "sentry.api.endpoints.organization_profiling_functions", "sentry.api.endpoints.organization_releases", - "sentry.api.endpoints.organization_sdk_updates", - "sentry.api.endpoints.organization_sessions", "sentry.api.endpoints.organization_stats_summary", "sentry.api.endpoints.organization_stats_v2", "sentry.api.endpoints.organization_trace_item_attributes", @@ -480,9 +474,7 @@ module = [ "sentry.api.endpoints.organization_unsubscribe", "sentry.api.endpoints.project_artifact_bundle_files", "sentry.api.endpoints.project_performance_general_settings", - "sentry.api.endpoints.project_servicehook_details", "sentry.api.endpoints.project_servicehooks", - "sentry.api.endpoints.project_tagkey_details", "sentry.api.endpoints.project_trace_item_details", "sentry.api.endpoints.project_transaction_threshold", "sentry.api.endpoints.project_transaction_threshold_override", @@ -492,9 +484,7 @@ module = [ "sentry.api.paginator", "sentry.api.serializers.models.artifactbundle", "sentry.api.serializers.models.dashboard", - "sentry.api.serializers.models.discoversavedquery", "sentry.api.serializers.models.event", - "sentry.api.serializers.models.group", "sentry.api.serializers.models.group_stream", "sentry.api.serializers.models.groupseen", "sentry.api.serializers.models.project", @@ -503,8 +493,6 @@ module = [ "sentry.api.serializers.models.userrollback", "sentry.api.serializers.rest_framework.base", "sentry.api.serializers.rest_framework.dashboard", - "sentry.api.serializers.rest_framework.environment", - "sentry.api.serializers.rest_framework.groupsearchview", "sentry.api.serializers.rest_framework.release", "sentry.api.serializers.snuba", "sentry.api.validators.*", @@ -546,12 +534,10 @@ module = [ "sentry.feedback.lib.*", "sentry.feedback.usecases.*", "sentry.flags.endpoints.secrets", - "sentry.flags.models", "sentry.flags.providers", "sentry.grouping.enhancer.rules", "sentry.grouping.strategies.*", "sentry.http", - "sentry.identity.base", "sentry.identity.github.*", "sentry.identity.gitlab.*", "sentry.identity.google.*", @@ -586,8 +572,6 @@ module = [ "sentry.integrations.github.client", "sentry.integrations.github.installation", "sentry.integrations.github.integration", - "sentry.integrations.github.issue_sync", - "sentry.integrations.github.search", "sentry.integrations.github.tasks.*", "sentry.integrations.github_copilot.*", "sentry.integrations.github_enterprise.client", @@ -610,7 +594,6 @@ module = [ "sentry.integrations.msteams.actions.*", "sentry.integrations.msteams.card_builder.*", "sentry.integrations.msteams.client", - "sentry.integrations.msteams.integration", "sentry.integrations.msteams.utils", "sentry.integrations.msteams.webhook", "sentry.integrations.on_call.*", @@ -626,15 +609,12 @@ module = [ "sentry.integrations.tasks.*", "sentry.integrations.time_utils", "sentry.integrations.vsts.client", - "sentry.integrations.vsts.search", "sentry.interfaces.*", "sentry.issues.attributes", "sentry.issues.endpoints.group_current_release", "sentry.issues.endpoints.organization_event_details", - "sentry.issues.endpoints.organization_issue_timeseries", "sentry.issues.endpoints.organization_issues_count", "sentry.issues.endpoints.project_ownership", - "sentry.issues.endpoints.project_performance_issue_settings", "sentry.issues.endpoints.project_user_issue", "sentry.issues.highlights", "sentry.issues.issue_search", @@ -651,13 +631,11 @@ module = [ "sentry.middleware.devtoolbar", "sentry.models.artifactbundle", "sentry.models.auditlogentry", - "sentry.models.authidentity", "sentry.models.authprovider", "sentry.models.avatars.*", "sentry.models.commit", "sentry.models.commitfilechange", "sentry.models.counter", - "sentry.models.dashboard", "sentry.models.dashboard_widget", "sentry.models.deploy", "sentry.models.environment", @@ -678,10 +656,8 @@ module = [ "sentry.models.projectkey", "sentry.models.projectownership", "sentry.models.recentsearch", - "sentry.models.relay", "sentry.models.release", "sentry.models.release_threshold.*", - "sentry.models.releaseenvironment", "sentry.models.releasefile", "sentry.models.releases.*", "sentry.models.repository", @@ -689,7 +665,6 @@ module = [ "sentry.models.sourcemapprocessingissue", "sentry.models.team", "sentry.models.transaction_threshold", - "sentry.monitors.clock_dispatch", "sentry.monitors.endpoints.*", "sentry.monitors.logic.*", "sentry.monitors.models", @@ -698,7 +673,6 @@ module = [ "sentry.monitors.serializers", "sentry.monitors.tasks.*", "sentry.monitors.testutils", - "sentry.monitors.types", "sentry.monitors.utils", "sentry.monitors.validators", "sentry.net.http", @@ -728,13 +702,11 @@ module = [ "sentry.releases.endpoints.*", "sentry.remote_subscriptions.*", "sentry.replays._case_studies.*", - "sentry.replays.data_export", "sentry.replays.endpoints.*", "sentry.replays.lib.cache", "sentry.replays.lib.eap.*", "sentry.replays.lib.kafka", "sentry.replays.lib.query", - "sentry.replays.lib.storage", "sentry.replays.query", "sentry.replays.testutils", "sentry.replays.usecases.*", @@ -765,10 +737,8 @@ module = [ "sentry.services.http", "sentry.services.organization.*", "sentry.shared_integrations.response.*", - "sentry.signals", "sentry.similarity.*", "sentry.snuba.functions", - "sentry.snuba.issue_platform", "sentry.snuba.metrics.fields.*", "sentry.snuba.metrics.mqb_query_transformer", "sentry.snuba.metrics.query_builder", @@ -788,12 +758,10 @@ module = [ "sentry.tasks.auth.*", "sentry.tasks.auto_ongoing_issues", "sentry.tasks.auto_resolve_issues", - "sentry.tasks.base", "sentry.tasks.collect_project_platforms", "sentry.tasks.embeddings_grouping.*", "sentry.tasks.files", "sentry.tasks.groupowner", - "sentry.tasks.options", "sentry.tasks.process_buffer", "sentry.tasks.relay", "sentry.tasks.release_registry", @@ -803,7 +771,6 @@ module = [ "sentry.tasks.statistical_detectors", "sentry.tasks.summaries.*", "sentry.tasks.user_report", - "sentry.tempest.tasks", "sentry.templatetags.*", "sentry.testutils.asserts", "sentry.testutils.cases", @@ -814,7 +781,6 @@ module = [ "sentry.testutils.helpers.datetime", "sentry.testutils.helpers.features", "sentry.testutils.helpers.notifications", - "sentry.testutils.helpers.options", "sentry.testutils.helpers.slack", "sentry.testutils.hybrid_cloud", "sentry.testutils.issue_detection.*", @@ -856,18 +822,14 @@ module = [ "sentry.web.client_config", "sentry.web.forms.*", "sentry.web.frontend.auth_login", - "sentry.web.frontend.auth_organization_login", "sentry.web.frontend.disabled_member_view", "sentry.web.frontend.generic", "sentry.web.frontend.mailgun_inbound_webhook", "sentry.web.frontend.oauth_token", - "sentry.web.frontend.openidtoken", "sentry.web.frontend.react_page", "sentry.web.frontend.release_webhook", "sentry.web.frontend.setup_wizard", "social_auth.backends.*", - "social_auth.decorators", - "social_auth.exceptions", "social_auth.fields", "social_auth.models", "social_auth.utils", diff --git a/src/sentry/event_manager.py b/src/sentry/event_manager.py index a15528476d5b..453d3379f09b 100644 --- a/src/sentry/event_manager.py +++ b/src/sentry/event_manager.py @@ -999,7 +999,7 @@ def _get_or_create_release_associated_models( environment = job["environment"] date = job["event"].datetime - release_env_tags: dict[str, str] = {} + release_env_tags: MutableTags = {} ReleaseEnvironment.get_or_create( project=project, release=release, diff --git a/src/sentry/flags/endpoints/logs.py b/src/sentry/flags/endpoints/logs.py index 5fbcb822497e..740bcf7272c0 100644 --- a/src/sentry/flags/endpoints/logs.py +++ b/src/sentry/flags/endpoints/logs.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from typing import Any, Literal, TypedDict +from typing import Any, Literal, TypedDict, cast from django.contrib.auth.models import AnonymousUser from django.db.models import Q @@ -70,13 +70,18 @@ def serialize( ) -> FlagAuditLogModelSerializerResponse: return { "id": obj.id, - "action": ActionEnum.to_string(obj.action), + "action": cast( + Literal["created", "deleted", "updated"], ActionEnum.to_string(obj.action) + ), "createdAt": obj.created_at.isoformat(), "createdBy": obj.created_by, "createdByType": ( None if obj.created_by_type is None - else CreatedByTypeEnum.to_string(obj.created_by_type) + else cast( + Literal["email", "id", "name"], + CreatedByTypeEnum.to_string(obj.created_by_type), + ) ), "flag": obj.flag, "provider": (None if obj.provider is None else ProviderEnum.to_string(obj.provider)), diff --git a/src/sentry/identity/datadog/provider.py b/src/sentry/identity/datadog/provider.py index 0eab4b7e120f..26bd34460ca2 100644 --- a/src/sentry/identity/datadog/provider.py +++ b/src/sentry/identity/datadog/provider.py @@ -3,7 +3,8 @@ import base64 import hashlib import secrets -from typing import TYPE_CHECKING, Any, NotRequired, TypedDict +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast import orjson import sentry_sdk @@ -331,15 +332,15 @@ def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]: ), ] - def get_oauth_data(self, payload: dict[str, Any]) -> dict[str, Any]: + def get_oauth_data(self, payload: Mapping[str, object]) -> dict[str, Any]: data = super().get_oauth_data(payload) if "scope" in payload: data["scope"] = payload["scope"] return data - def build_identity(self, data: dict[str, Any]) -> dict[str, Any]: - token_data = data["data"] - access_token = token_data.get("access_token") + def build_identity(self, data: Mapping[str, object]) -> dict[str, Any]: + token_data = cast(Mapping[str, object], data["data"]) + access_token = cast(str | None, token_data.get("access_token")) if not access_token: raise ValueError("Datadog token exchange did not return an access_token") @@ -353,8 +354,8 @@ def build_identity(self, data: dict[str, Any]) -> dict[str, Any]: oauth_data = self.get_oauth_data(token_data) # Persist DCR credentials and site so refresh_identity can access them outside a pipeline context. - client_id = data.get("dcr_client_id") - client_secret = data.get("dcr_client_secret") + client_id = cast(str | None, data.get("dcr_client_id")) + client_secret = cast(str | None, data.get("dcr_client_secret")) if not client_id or not client_secret: raise IdentityNotValid("Missing DCR credentials") oauth_data["client_id"] = client_id @@ -432,12 +433,12 @@ def build_mcp_urls(self, identity_data: dict[str, Any]) -> list[str]: base = mcp_base_url_for_site(identity_data.get("site")) return [f"{base}{MCP_ENDPOINT_PATH}"] if base else [] - def build_identity(self, data: dict[str, Any]) -> dict[str, Any]: - access_token = (data.get("access_token") or "").strip() + def build_identity(self, data: Mapping[str, object]) -> dict[str, Any]: + access_token = cast(str, data.get("access_token") or "").strip() if not access_token: raise ValueError("Datadog requires an 'access_token' parameter.") - site = data.get("site") + site = cast(str | None, data.get("site")) base = mcp_base_url_for_site(site) if not site: raise ValueError("Datadog requires a 'site' parameter (e.g. 'datadoghq.com').") diff --git a/src/sentry/identity/gcp/provider.py b/src/sentry/identity/gcp/provider.py index 1ebf40a25a13..e444f70d579e 100644 --- a/src/sentry/identity/gcp/provider.py +++ b/src/sentry/identity/gcp/provider.py @@ -1,7 +1,8 @@ from __future__ import annotations import logging -from typing import Any +from collections.abc import Mapping +from typing import Any, cast import orjson @@ -80,11 +81,11 @@ def make_oauth_api_step(self, **kwargs: Any) -> OAuth2ApiStep: **kwargs, ) - def build_identity(self, state: dict[str, Any]) -> dict[str, Any]: - data = state.get("data", {}) + def build_identity(self, state: Mapping[str, object]) -> dict[str, Any]: + data = cast(Mapping[str, object], state.get("data", {})) try: - id_token = data["id_token"] + id_token = cast(str, data["id_token"]) except KeyError: raise IdentityNotValid("Missing id_token in OAuth response") diff --git a/src/sentry/identity/pipeline.py b/src/sentry/identity/pipeline.py index fae0e6b13fb6..f12924b1a586 100644 --- a/src/sentry/identity/pipeline.py +++ b/src/sentry/identity/pipeline.py @@ -3,6 +3,7 @@ import logging from collections.abc import Callable, Sequence from functools import cached_property +from typing import cast from django.contrib import messages from django.db import router, transaction @@ -80,7 +81,7 @@ def finish_pipeline(self) -> HttpResponseBase: linked_identity = Identity.objects.link_identity( user=self.request.user, idp=self.provider_model, - external_id=identity["id"], + external_id=cast(str, identity["id"]), should_reattach=False, defaults={ "scopes": identity.get("scopes", []), @@ -101,7 +102,9 @@ def finish_pipeline(self) -> HttpResponseBase: # Let providers react to a freshly linked identity (e.g. backfilling # derived mappings). Best-effort: never let it break the link flow. try: - self.provider.post_link_identity(identity, self.request.user.id) + self.provider.post_link_identity( + cast(dict[str, object], identity), self.request.user.id + ) except Exception: logger.exception( "identity.post_link_identity.failed", diff --git a/src/sentry/integrations/github/search.py b/src/sentry/integrations/github/search.py index 5f646592b2bd..7e607a407c7b 100644 --- a/src/sentry/integrations/github/search.py +++ b/src/sentry/integrations/github/search.py @@ -1,4 +1,4 @@ -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from rest_framework.response import Response @@ -19,11 +19,14 @@ PAGE_LIMIT = 1 +if TYPE_CHECKING: + _SearchEndpointBase = SourceCodeSearchEndpoint[GitHubIntegration | GitHubEnterpriseIntegration] +else: + _SearchEndpointBase = SourceCodeSearchEndpoint + @control_silo_endpoint -class GithubSharedSearchEndpoint( - SourceCodeSearchEndpoint[GitHubIntegration | GitHubEnterpriseIntegration] -): +class GithubSharedSearchEndpoint(_SearchEndpointBase): """NOTE: This endpoint is a shared search endpoint for Github and Github Enterprise integrations.""" @property diff --git a/src/sentry/integrations/vsts/search.py b/src/sentry/integrations/vsts/search.py index 11373fe052ae..35b149f231b6 100644 --- a/src/sentry/integrations/vsts/search.py +++ b/src/sentry/integrations/vsts/search.py @@ -1,4 +1,4 @@ -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from rest_framework.response import Response @@ -11,9 +11,14 @@ T = TypeVar("T", bound=SourceCodeIssueIntegration) +if TYPE_CHECKING: + _SearchEndpointBase = SourceCodeSearchEndpoint[VstsIntegration] +else: + _SearchEndpointBase = SourceCodeSearchEndpoint + @control_silo_endpoint -class VstsSearchEndpoint(SourceCodeSearchEndpoint[VstsIntegration]): +class VstsSearchEndpoint(_SearchEndpointBase): @property def integration_provider(self) -> str: return IntegrationProviderSlug.AZURE_DEVOPS.value diff --git a/src/sentry/models/releaseenvironment.py b/src/sentry/models/releaseenvironment.py index 53b4516f459e..44b9c30b236c 100644 --- a/src/sentry/models/releaseenvironment.py +++ b/src/sentry/models/releaseenvironment.py @@ -1,8 +1,7 @@ from __future__ import annotations -from collections.abc import MutableMapping from datetime import datetime -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from django.db import models from django.utils import timezone @@ -15,6 +14,7 @@ cell_silo_model, sane_repr, ) +from sentry.metrics.base import MutableTags from sentry.models.metric_tags import DATA_ACCESS_TAG, DataAccessTagValues from sentry.utils import metrics from sentry.utils.cache import cache @@ -56,11 +56,11 @@ def get_or_create( release: Release, environment: Environment, datetime: datetime, - metrics_tags: MutableMapping[str, str] | None = None, + metrics_tags: MutableTags | None = None, **kwargs: object, ) -> ReleaseEnvironment: with metrics.timer("models.releaseenvironment.get_or_create") as timer_tags: - tags = cast(dict[str, str], timer_tags) + tags = timer_tags instance = cls._get_or_create_impl(project, release, environment, datetime, tags) if metrics_tags is not None: metrics_tags.update(tags) @@ -73,7 +73,7 @@ def _get_or_create_impl( release: Release, environment: Environment, datetime: datetime, - metric_tags: dict[str, str], + metric_tags: MutableTags, ) -> ReleaseEnvironment: cache_key = cls.get_cache_key(project.id, release.id, environment.id) diff --git a/src/sentry/signals.py b/src/sentry/signals.py index 5e78b7be3c08..6099c07c7b12 100644 --- a/src/sentry/signals.py +++ b/src/sentry/signals.py @@ -4,13 +4,15 @@ import functools import logging from collections.abc import Callable, Hashable -from typing import Any, overload +from typing import ParamSpec, TypeVar, cast, overload from django.dispatch.dispatcher import Signal from sentry.utils.env import in_test_environment -Receiver = Callable[[], Any] +P = ParamSpec("P") +R = TypeVar("R") +Receiver = Callable[..., object] _AllReceivers = enum.Enum("_AllReceivers", "ALL") @@ -26,7 +28,7 @@ class receivers_raise_on_send: This behavior only works in tests. """ - receivers: Any + receivers: _AllReceivers | Receiver | list[Receiver] def __init__(self, receivers: _AllReceivers | Receiver | list[Receiver] = _AllReceivers.ALL): self.receivers = receivers @@ -38,11 +40,14 @@ def __enter__(self) -> None: if self.receivers is _AllReceivers.ALL: _receivers_that_raise = self.receivers else: - _receivers_that_raise += self.receivers + receivers = self.receivers + if not isinstance(receivers, list): + receivers = [receivers] + _receivers_that_raise = cast(list[Receiver], _receivers_that_raise) + receivers - def __call__(self, f: Callable[..., Any]) -> Callable[..., Any]: + def __call__(self, f: Callable[P, R]) -> Callable[P, R]: @functools.wraps(f) - def wrapped(*args: Any, **kwds: Any) -> Any: + def wrapped(*args: P.args, **kwds: P.kwargs) -> R: with receivers_raise_on_send(self.receivers): return f(*args, **kwds) @@ -62,12 +67,12 @@ def connect( sender: object | None = None, weak: bool = True, dispatch_uid: Hashable | None = None, - ) -> Callable[[Callable[..., Any]], None]: ... + ) -> Callable[[Callable[..., object]], None]: ... @overload def connect( self, - receiver: Callable[..., Any], + receiver: Callable[..., object], sender: object | None = None, weak: bool = True, dispatch_uid: Hashable | None = None, @@ -75,11 +80,11 @@ def connect( def connect( self, - receiver: Callable[..., Any] | None = None, + receiver: Callable[..., object] | None = None, sender: object | None = None, weak: bool = True, dispatch_uid: Hashable | None = None, - ) -> Callable[[Callable[..., Any]], None] | None: + ) -> Callable[[Callable[..., object]], None] | None: """ Support decorator syntax: @@ -89,7 +94,7 @@ def connect( """ - def wrapped(func: Callable[..., Any]) -> None: + def wrapped(func: Callable[..., object]) -> None: super(BetterSignal, self).connect( func, sender=sender, weak=weak, dispatch_uid=dispatch_uid ) diff --git a/src/sentry/testutils/helpers/options.py b/src/sentry/testutils/helpers/options.py index e56d2d548f0c..9a9e40f955f0 100644 --- a/src/sentry/testutils/helpers/options.py +++ b/src/sentry/testutils/helpers/options.py @@ -1,3 +1,5 @@ +from __future__ import annotations + __all__ = ["override_options"] from collections.abc import Iterator, Mapping diff --git a/src/sentry/users/models/identity.py b/src/sentry/users/models/identity.py index b5629d3d67cf..c7606836c00f 100644 --- a/src/sentry/users/models/identity.py +++ b/src/sentry/users/models/identity.py @@ -2,7 +2,7 @@ import logging from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, cast from django.conf import settings from django.contrib.postgres.fields.array import ArrayField @@ -261,20 +261,20 @@ class Meta: def link_provider_identity( user: User | RpcUser, - identity_data: dict[str, Any], + identity_data: Mapping[str, object], organization_id: int, ) -> Identity | None: with transaction.atomic(router.db_for_write(Identity)): idp, _ = IdentityProvider.objects.get_or_create( - type=identity_data["type"], - external_id=identity_data["idp_external_id"], + type=cast(str, identity_data["type"]), + external_id=cast(str, identity_data["idp_external_id"]), defaults={"config": identity_data.get("idp_config", {})}, ) linked_identity = Identity.objects.link_identity( user=user, idp=idp, - external_id=identity_data["id"], + external_id=cast(str, identity_data["id"]), should_reattach=False, defaults={ "scopes": identity_data.get("scopes", []), diff --git a/tests/sentry/sentry_metrics/consumers/test_schema_validator.py b/tests/sentry/sentry_metrics/consumers/test_schema_validator.py index d9792908e049..65ac429f6297 100644 --- a/tests/sentry/sentry_metrics/consumers/test_schema_validator.py +++ b/tests/sentry/sentry_metrics/consumers/test_schema_validator.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from typing import Any +from typing import Any, cast import pytest from sentry_kafka_schemas.codecs import Codec, ValidationError @@ -194,7 +194,7 @@ def test_metrics_schema_validator( """ Test the behavior of the MetricsSchemaValidator class with different parameters. """ - with override_options({option_name: option_value}): + with override_options({cast(str, option_name): option_value}): validator = MetricsSchemaValidator(codec, option_name if option_name else None) if is_valid: validator.validate(use_case_id, message) From 4e280921ee9f312fb32c200d2087a790330c39c4 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:39:46 -0700 Subject: [PATCH 035/103] ref(typing): graduate API exception weaklist module Annotate the exception constructors and detail payload, then remove the weaklist exemption. Full mypy passes. --- pyproject.toml | 1 - src/sentry/api/exceptions.py | 27 ++++++++++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a011f479dad8..64bc4037d71a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -479,7 +479,6 @@ module = [ "sentry.api.endpoints.project_transaction_threshold", "sentry.api.endpoints.project_transaction_threshold_override", "sentry.api.endpoints.relay.*", - "sentry.api.exceptions", "sentry.api.fields.*", "sentry.api.paginator", "sentry.api.serializers.models.artifactbundle", diff --git a/src/sentry/api/exceptions.py b/src/sentry/api/exceptions.py index 2e1c429981b2..b4ab0bd9d907 100644 --- a/src/sentry/api/exceptions.py +++ b/src/sentry/api/exceptions.py @@ -1,8 +1,10 @@ from __future__ import annotations from collections.abc import Iterable +from typing import TYPE_CHECKING, cast from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth.models import AnonymousUser from django.http.request import HttpRequest from django.urls import reverse from rest_framework import status @@ -10,6 +12,11 @@ from sentry.models.organization import Organization from sentry.organizations.services.organization.model import RpcOrganization +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser + +if TYPE_CHECKING: + from rest_framework.exceptions import _Detail from sentry.utils.auth import construct_link_with_query from sentry.utils.http import is_using_customer_domain @@ -43,7 +50,13 @@ class SentryAPIException(APIException): code = "" message = "" - def __init__(self, code=None, message=None, detail=None, **kwargs): + def __init__( + self, + code: str | None = None, + message: str | None = None, + detail: object = None, + **kwargs: object, + ) -> None: # Note that we no longer call the base `__init__` here. This is because # DRF now forces all detail messages that subclass `APIException` to a # string, which breaks our format. @@ -55,7 +68,7 @@ def __init__(self, code=None, message=None, detail=None, **kwargs): "extra": kwargs, } - self.detail = {"detail": detail} + self.detail = cast("dict[str, _Detail]", {"detail": detail}) class BadRequest(SentryAPIException): @@ -81,8 +94,8 @@ def __init__( self, organization: Organization | RpcOrganization, request: HttpRequest, - after_login_redirect=None, - ): + after_login_redirect: str | None = None, + ) -> None: login_url = reverse("sentry-auth-organization", args=[organization.slug]) if is_using_customer_domain(request): login_url = organization.absolute_url(path=login_url) @@ -99,7 +112,7 @@ class MemberDisabledOverLimit(SentryAPIException): code = "member-disabled-over-limit" message = "Organization over member limit" - def __init__(self, organization): + def __init__(self, organization: Organization | RpcOrganization) -> None: super().__init__( next=reverse("sentry-organization-disabled-member", args=[organization.slug]) ) @@ -135,7 +148,7 @@ class SudoRequired(SentryAPIException): code = "sudo-required" message = "Account verification required." - def __init__(self, user): + def __init__(self, user: User | RpcUser | AnonymousUser) -> None: super().__init__(username=user.username) @@ -144,7 +157,7 @@ class PrimaryEmailVerificationRequired(SentryAPIException): code = "primary-email-verification-required" message = "Primary email verification required." - def __init__(self, user): + def __init__(self, user: User | RpcUser | AnonymousUser) -> None: super().__init__(username=user.username) From c1049418202a6f9e10aa6161c5bd217fd630d18f Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:45:13 -0700 Subject: [PATCH 036/103] ref(typing): graduate release file weaklist module Type artifact index reads/writes and the update return contract, then remove the weaklist exemption. Full mypy and release-file tests pass. --- pyproject.toml | 1 - src/sentry/models/releasefile.py | 26 ++++++++++++------- .../endpoints/project_release_file_details.py | 2 +- tests/sentry/models/test_releasefile.py | 3 ++- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 64bc4037d71a..dc7aba4f4cca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -657,7 +657,6 @@ module = [ "sentry.models.recentsearch", "sentry.models.release", "sentry.models.release_threshold.*", - "sentry.models.releasefile", "sentry.models.releases.*", "sentry.models.repository", "sentry.models.rule", diff --git a/src/sentry/models/releasefile.py b/src/sentry/models/releasefile.py index 498465c348af..158ccc5e71ef 100644 --- a/src/sentry/models/releasefile.py +++ b/src/sentry/models/releasefile.py @@ -8,7 +8,7 @@ from io import BytesIO from tempfile import TemporaryDirectory from types import TracebackType -from typing import IO, ClassVar, Iterable, Iterator, Mapping, Self, cast +from typing import IO, ClassVar, Iterable, Iterator, Mapping, Self, TypedDict, cast from urllib.parse import urlunsplit from django.db import models, router @@ -40,6 +40,10 @@ logger = logging.getLogger(__name__) +class ArtifactIndex(TypedDict, total=False): + files: dict[str, dict[str, object]] + + def _load_json_object(data: str | bytes) -> dict[str, object]: raw = json.loads(data) if not isinstance(raw, dict): @@ -254,9 +258,9 @@ def __init__(self, data: dict[str, object], fresh: bool = False) -> None: self.changed = fresh @property - def data(self) -> dict[str, object]: + def data(self) -> ArtifactIndex: """Meant to be read-only""" - return self._data + return cast(ArtifactIndex, self._data) @property def num_files(self) -> int: @@ -286,7 +290,7 @@ def delete(self, filename: str) -> bool: class _ArtifactIndexGuard: """Ensures atomic write operations to the artifact index""" - def __init__(self, release: Release, dist: Distribution | None, **filter_args) -> None: + def __init__(self, release: Release, dist: Distribution | None, **filter_args: object) -> None: self._release = release self._dist = dist self._ident = ReleaseFile.get_ident( @@ -294,7 +298,7 @@ def __init__(self, release: Release, dist: Distribution | None, **filter_args) - ) self._filter_args = filter_args # Extra constraints on artifact index release file - def readable_data(self) -> dict[str, object] | None: + def readable_data(self) -> ArtifactIndex | None: """Simple read, no synchronization necessary""" try: releasefile = self._releasefile_qs()[0] @@ -303,7 +307,7 @@ def readable_data(self) -> dict[str, object] | None: else: fp = releasefile.file.getfile() with fp: - return _load_json_object_from_file(fp) + return cast(ArtifactIndex, _load_json_object_from_file(fp)) @contextmanager def writable_data( @@ -392,7 +396,9 @@ def _key_fields(self) -> dict[str, int | str | None]: @traces.trace -def read_artifact_index(release: Release, dist: Distribution | None, **filter_args) -> dict | None: +def read_artifact_index( + release: Release, dist: Distribution | None, **filter_args: object +) -> ArtifactIndex | None: """Get index data""" guard = _ArtifactIndexGuard(release, dist, **filter_args) return guard.readable_data() @@ -408,8 +414,8 @@ def update_artifact_index( release: Release, dist: Distribution | None, archive_file: File, - temp_file: IO | None = None, -): + temp_file: IO[bytes] | None = None, +) -> ReleaseFile | None: """Add information from release archive to artifact index :returns: The created ReleaseFile instance @@ -429,7 +435,7 @@ def update_artifact_index( files = cast(dict[str, object], manifest.get("files", {})) if not files: - return + return None for filename, info in files.items(): info = cast(dict[str, object], info).copy() diff --git a/src/sentry/releases/endpoints/project_release_file_details.py b/src/sentry/releases/endpoints/project_release_file_details.py index 6cd8c36b7fd9..1db3377f104a 100644 --- a/src/sentry/releases/endpoints/project_release_file_details.py +++ b/src/sentry/releases/endpoints/project_release_file_details.py @@ -47,7 +47,7 @@ class ReleaseFileSerializer(serializers.Serializer): ) -def _entry_from_index(release: Release, dist: Distribution | None, url: str) -> ReleaseFile: +def _entry_from_index(release: Release, dist: Distribution | None, url: str) -> dict[str, object]: index = read_artifact_index(release, dist) if index is None: raise ResourceDoesNotExist diff --git a/tests/sentry/models/test_releasefile.py b/tests/sentry/models/test_releasefile.py index 7712861c686a..250b48c0dd77 100644 --- a/tests/sentry/models/test_releasefile.py +++ b/tests/sentry/models/test_releasefile.py @@ -11,6 +11,7 @@ from sentry.models.files.file import File from sentry.models.releasefile import ( ARTIFACT_INDEX_FILENAME, + ArtifactIndex, ReleaseArchive, ReleaseFile, _ArtifactIndexGuard, @@ -166,7 +167,7 @@ def test_multi_archive(self) -> None: ) # Two files were overwritten, one was added - expected = { + expected: ArtifactIndex = { "files": { "fake://bar": { "archive_ident": archive2.ident, From a816f3bcc278d299057ed9b463d65c1b5367466c Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:40:45 -0700 Subject: [PATCH 037/103] ref(typing): graduate lane 7 weaklist modules --- .../endpoints/accept_organization_invite.py | 10 +++---- .../api/endpoints/project_servicehooks.py | 9 ++++--- src/sentry/db/models/fields/slug.py | 21 ++++++++++++--- .../organization_code_mapping_codeowners.py | 26 ++++++++++++++++--- .../integrations/models/external_actor.py | 10 ++++--- src/sentry/middleware/customer_domain.py | 16 ++++++------ src/sentry/models/sourcemapprocessingissue.py | 8 +++--- src/sentry/spans/buffer.py | 8 +++--- src/sentry/tasks/release_registry.py | 8 +++--- 9 files changed, 76 insertions(+), 40 deletions(-) diff --git a/src/sentry/api/endpoints/accept_organization_invite.py b/src/sentry/api/endpoints/accept_organization_invite.py index c67ab713f262..95bb0911c033 100644 --- a/src/sentry/api/endpoints/accept_organization_invite.py +++ b/src/sentry/api/endpoints/accept_organization_invite.py @@ -58,9 +58,9 @@ def convert_args( member_id: str, token: str, organization_id_or_slug: str, - *args, - **kwargs, - ): + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: # Demo users will be logged out in get() before processing the invite, # so we should fetch the invite context as if they were anonymous. # This matches the original behavior where logout happened before get_invite_state(). @@ -93,7 +93,7 @@ def get( member_id: str, token: str, invite_context: RpcUserInviteContext, - **kwargs, + **kwargs: object, ) -> Response | HttpResponse: # Demo user can't accept invites, this invite is probably meant for another user # so we log out the demo user and let the invite flow continue since it can handle @@ -201,7 +201,7 @@ def post( request: Request, token: str, invite_context: RpcUserInviteContext, - **kwargs, + **kwargs: object, ) -> Response: if is_demo_user(request.user): return Response(status=status.HTTP_403_FORBIDDEN) diff --git a/src/sentry/api/endpoints/project_servicehooks.py b/src/sentry/api/endpoints/project_servicehooks.py index 044ed446f10d..d43d6fa100e5 100644 --- a/src/sentry/api/endpoints/project_servicehooks.py +++ b/src/sentry/api/endpoints/project_servicehooks.py @@ -20,6 +20,7 @@ from sentry.apidocs.parameters import CursorQueryParam, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import ObjectStatus +from sentry.models.project import Project from sentry.sentry_apps.api.parsers.servicehook import ServiceHookValidator from sentry.sentry_apps.api.serializers.servicehook import ( ServiceHookSerializer, @@ -38,7 +39,7 @@ class ProjectServiceHooksEndpoint(ProjectEndpoint): "POST": ApiPublishStatus.PRIVATE, } - def has_feature(self, request: Request, project): + def has_feature(self, request: Request, project: Project) -> bool: return features.has("projects:servicehooks", project=project, actor=request.user) @extend_schema( @@ -58,7 +59,9 @@ def has_feature(self, request: Request, project): 404: RESPONSE_NOT_FOUND, }, ) - def get(self, request: Request, project) -> Response[list[ServiceHookSerializerResponse]]: + def get( + self, request: Request, project: Project + ) -> Response[list[ServiceHookSerializerResponse]]: """ Return a list of service hooks bound to a project. @@ -102,7 +105,7 @@ def get(self, request: Request, project) -> Response[list[ServiceHookSerializerR 404: RESPONSE_NOT_FOUND, }, ) - def post(self, request: Request, project) -> Response[ServiceHookSerializerResponse]: + def post(self, request: Request, project: Project) -> Response[ServiceHookSerializerResponse]: """ Register a new service hook on a project. diff --git a/src/sentry/db/models/fields/slug.py b/src/sentry/db/models/fields/slug.py index 94e569fcfb15..af780bf4fdbb 100644 --- a/src/sentry/db/models/fields/slug.py +++ b/src/sentry/db/models/fields/slug.py @@ -1,24 +1,37 @@ +from typing import TYPE_CHECKING + +from django.db.backends.base.base import BaseDatabaseWrapper from django.db.models import SlugField from django.db.models.lookups import Lookup +from django.db.models.sql.compiler import SQLCompiler from sentry.db.models.fields.bounded import BoundedBigAutoField from sentry.utils.slug import no_numeric_validator, org_slug_validator DEFAULT_SLUG_MAX_LENGTH = 50 +if TYPE_CHECKING: + _SlugField = SlugField[str, str] + _Lookup = Lookup[str] +else: + _SlugField = SlugField + _Lookup = Lookup + -class SentrySlugField(SlugField): +class SentrySlugField(_SlugField): default_validators = [*SlugField.default_validators, no_numeric_validator] -class SentryOrgSlugField(SlugField): +class SentryOrgSlugField(_SlugField): default_validators = [*SlugField.default_validators, org_slug_validator] -class IdOrSlugLookup(Lookup): +class IdOrSlugLookup(_Lookup): lookup_name = "id_or_slug" - def as_sql(self, compiler, connection): + def as_sql( + self, compiler: SQLCompiler, connection: BaseDatabaseWrapper + ) -> tuple[str, list[str | int] | tuple[str | int, ...]]: lhs, lhs_params = self.process_lhs(compiler, connection) rhs, rhs_params = self.process_rhs(compiler, connection) diff --git a/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py b/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py index 44caaf5dd0a0..bcdecbc62fc9 100644 --- a/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py +++ b/src/sentry/integrations/api/endpoints/organization_code_mapping_codeowners.py @@ -1,3 +1,6 @@ +from collections.abc import Mapping +from typing import cast + from django.http import Http404 from rest_framework import status from rest_framework.exceptions import NotFound @@ -13,10 +16,11 @@ from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig from sentry.integrations.services.integration import integration_service from sentry.integrations.source_code_management.repository import RepositoryIntegration +from sentry.models.organization import Organization from sentry.shared_integrations.exceptions import ApiError -def get_codeowner_contents(config): +def get_codeowner_contents(config: RepositoryProjectPathConfig) -> Mapping[str, str] | None: if not config.organization_integration_id: raise NotFound(detail="No associated integration") @@ -30,6 +34,7 @@ def get_codeowner_contents(config): install = integration.get_installation(organization_id=org_id) if isinstance(install, RepositoryIntegration): return install.get_codeowner_file(repository, ref=config.default_branch) + return None @cell_silo_endpoint @@ -40,11 +45,18 @@ class OrganizationCodeMappingCodeOwnersEndpoint(OrganizationEndpoint): } permission_classes = (OrganizationIntegrationsPermission,) - def convert_args(self, request: Request, organization_id_or_slug, config_id, *args, **kwargs): + def convert_args( + self, + request: Request, + organization_id_or_slug: str, + config_id: str, + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: args, kwargs = super().convert_args( request, organization_id_or_slug, config_id, *args, **kwargs ) - organization = kwargs["organization"] + organization = cast(Organization, kwargs["organization"]) try: kwargs["config"] = RepositoryProjectPathConfig.objects.select_related( @@ -59,7 +71,13 @@ def convert_args(self, request: Request, organization_id_or_slug, config_id, *ar return (args, kwargs) - def get(self, request: Request, config_id, organization, config) -> Response: + def get( + self, + request: Request, + config_id: str, + organization: Organization, + config: RepositoryProjectPathConfig, + ) -> Response: project = config.project_repository.project if not request.access.has_project_access(project): return self.respond(status=status.HTTP_403_FORBIDDEN) diff --git a/src/sentry/integrations/models/external_actor.py b/src/sentry/integrations/models/external_actor.py index 3af34f8644be..aa525a32f4f5 100644 --- a/src/sentry/integrations/models/external_actor.py +++ b/src/sentry/integrations/models/external_actor.py @@ -69,7 +69,9 @@ class Meta: ), ] - def delete(self, *args, **kwargs): + def delete( + self, using: str | None = None, keep_parents: bool = False + ) -> tuple[int, dict[str, int]]: from sentry.integrations.services.integration import integration_service # TODO: Extract this out of the delete method into the endpoint / controller instead. @@ -85,14 +87,14 @@ def delete(self, *args, **kwargs): team_id=team.id, provider=ExternalProviders(self.provider) ) - return super().delete(*args, **kwargs) + return super().delete(using=using, keep_parents=keep_parents) -def process_resource_change(instance: ExternalActor, **kwargs): +def process_resource_change(instance: ExternalActor, **kwargs: object) -> None: from sentry.models.project import Project from sentry.tasks.codeowners import update_code_owners_schema - def _spawn_task(): + def _spawn_task() -> None: update_code_owners_schema.apply_async( kwargs={ "organization": instance.organization_id, diff --git a/src/sentry/middleware/customer_domain.py b/src/sentry/middleware/customer_domain.py index 78180d3d24e1..9b20a3edda78 100644 --- a/src/sentry/middleware/customer_domain.py +++ b/src/sentry/middleware/customer_domain.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) -def _org_exists(slug): +def _org_exists(slug: str | None) -> bool: if not slug: return False return ( @@ -28,14 +28,14 @@ def _org_exists(slug): ) -def _query_string(request): +def _query_string(request: HttpRequest) -> str: qs = request.META.get("QUERY_STRING") or "" if qs: qs = f"?{qs}" return qs -def _resolve_activeorg(request): +def _resolve_activeorg(request: HttpRequest) -> str | None: subdomain = request.subdomain session = getattr(request, "session", None) if _org_exists(subdomain): @@ -46,7 +46,7 @@ def _resolve_activeorg(request): return None -def _resolve_redirect_url(request, activeorg): +def _resolve_redirect_url(request: HttpRequest, activeorg: str) -> str | None: subdomain = request.subdomain redirect_subdomain = subdomain != activeorg redirect_url = "" @@ -114,8 +114,8 @@ def __call__(self, request: HttpRequest) -> HttpResponseBase: del session["activeorg"] return self.get_response(request) auth.set_active_org(request, activeorg) - redirect_url = _resolve_redirect_url(request, activeorg) - if redirect_url is not None and len(redirect_url) > 0: - logger.info("customer_domain.redirect", extra={"location": redirect_url}) - return HttpResponseRedirect(redirect_url) + activeorg_redirect_url = _resolve_redirect_url(request, activeorg) + if activeorg_redirect_url is not None and len(activeorg_redirect_url) > 0: + logger.info("customer_domain.redirect", extra={"location": activeorg_redirect_url}) + return HttpResponseRedirect(activeorg_redirect_url) return self.get_response(request) diff --git a/src/sentry/models/sourcemapprocessingissue.py b/src/sentry/models/sourcemapprocessingissue.py index 5ec28d2b7aef..f2cd5bda7b81 100644 --- a/src/sentry/models/sourcemapprocessingissue.py +++ b/src/sentry/models/sourcemapprocessingissue.py @@ -28,16 +28,16 @@ class SourceMapProcessingIssue: } @classmethod - def get_message(cls, data): + def get_message(cls, data: str) -> str: return cls(data).message - def __init__(self, type, data=None): + def __init__(self, type: str, data: object | None = None) -> None: self.type = type self.data = data @property - def message(self): + def message(self) -> str: return self._messages.get(self.type, self._messages["unknown_error"]) - def get_api_context(self): + def get_api_context(self) -> dict[str, object]: return {"type": self.type, "message": self.message, "data": self.data} diff --git a/src/sentry/spans/buffer.py b/src/sentry/spans/buffer.py index 62f5002280d0..50b7d8a34a5c 100644 --- a/src/sentry/spans/buffer.py +++ b/src/sentry/spans/buffer.py @@ -176,7 +176,7 @@ def store(self) -> SpansBufferStore: return SpansBufferStore(self.client, self.assigned_shards, self.slice_id) # make it pickleable - def __reduce__(self): + def __reduce__(self) -> tuple[type[SpansBuffer], tuple[list[int], int | None]]: return (SpansBuffer, (self.assigned_shards, self.slice_id)) def _get_debug_trace_logger(self) -> DebugTraceLogger: @@ -185,7 +185,7 @@ def _get_debug_trace_logger(self) -> DebugTraceLogger: return self._debug_trace_logger @metrics.wraps("spans.buffer.process_spans") - def process_spans(self, spans: Sequence[Span], now: int): + def process_spans(self, spans: Sequence[Span], now: int) -> None: """ :param spans: List of to-be-ingested spans. :param now: The current time to be used for setting expiration/flush @@ -402,7 +402,7 @@ def _decompress_batch(self, compressed_data: bytes) -> list[bytes]: decompressed_buffer = self._zstd_decompressor.decompress(compressed_data) return decompressed_buffer.split(b"\x00") - def record_stored_segments(self): + def record_stored_segments(self) -> None: with metrics.timer("spans.buffer.get_stored_segments"): with self.client.pipeline(transaction=False) as p: for shard in self.assigned_shards: @@ -630,7 +630,7 @@ def _record_segment_loss_metrics( # worst-case. metrics.incr("spans.buffer.empty_segments") - def done_flush_segments(self, segment_keys: dict[SegmentKey, FlushedSegment]): + def done_flush_segments(self, segment_keys: dict[SegmentKey, FlushedSegment]) -> None: metrics.timing("spans.buffer.done_flush_segments.num_segments", len(segment_keys)) with metrics.timer("spans.buffer.done_flush_segments"): self.store.cleanup_flushed_segments(segment_keys) diff --git a/src/sentry/tasks/release_registry.py b/src/sentry/tasks/release_registry.py index 0ecebaf88fd8..1a4ea48c8cd9 100644 --- a/src/sentry/tasks/release_registry.py +++ b/src/sentry/tasks/release_registry.py @@ -20,7 +20,7 @@ CACHE_TTL = 3600 -def _fetch_registry_url(relative_url): +def _fetch_registry_url(relative_url: str) -> dict[str, object]: if not settings.SENTRY_RELEASE_REGISTRY_BASEURL: return {} @@ -38,7 +38,7 @@ def _fetch_registry_url(relative_url): return response.json() -def _fetch_release_registry_data(**kwargs): +def _fetch_release_registry_data(**kwargs: object) -> None: """ Fetch information about the latest SDK version from the release registry. @@ -72,7 +72,7 @@ def _fetch_release_registry_data(**kwargs): processing_deadline_duration=65, silo_mode=SiloMode.CELL, ) -def fetch_release_registry_data(**kwargs): +def fetch_release_registry_data(**kwargs: object) -> None: _fetch_release_registry_data(**kwargs) @@ -82,5 +82,5 @@ def fetch_release_registry_data(**kwargs): processing_deadline_duration=65, silo_mode=SiloMode.CONTROL, ) -def fetch_release_registry_data_control(**kwargs): +def fetch_release_registry_data_control(**kwargs: object) -> None: _fetch_release_registry_data(**kwargs) From 632e996ce98a009e21f1f17ef233a4502427f2a2 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:40:14 -0700 Subject: [PATCH 038/103] ref(typing): graduate lane 9 weaklist modules Add precise annotations to the nine lane 9 modules so they pass strict mypy without their disallow_untyped_defs weaklist entries. Payload-returning GitHub client methods use JSON-shaped dict/list types, notification filter kwargs accept object since their values are only compared, and Django model save/delete overrides mirror the inherited signatures. --- .../api/endpoints/organization_events_stats.py | 17 ++++++++++++----- .../api/serializers/rest_framework/release.py | 10 ++++++---- src/sentry/event_manager.py | 11 +++++++---- src/sentry/integrations/github/client.py | 10 +++++----- src/sentry/lang/java/exceptions.py | 8 ++++---- src/sentry/models/rule.py | 8 ++++---- .../notifications/notificationcontroller.py | 8 ++++---- src/sentry/tasks/process_buffer.py | 6 +++--- src/sentry/uptime/models.py | 12 +++++++----- 9 files changed, 52 insertions(+), 38 deletions(-) diff --git a/src/sentry/api/endpoints/organization_events_stats.py b/src/sentry/api/endpoints/organization_events_stats.py index f042624ba95d..0b1ba458f234 100644 --- a/src/sentry/api/endpoints/organization_events_stats.py +++ b/src/sentry/api/endpoints/organization_events_stats.py @@ -1,5 +1,5 @@ import logging -from collections.abc import Mapping +from collections.abc import Callable, Mapping from datetime import timedelta from typing import Any @@ -92,7 +92,9 @@ def get_features( } ) - def flatten_results(self, results: SnubaTSResult | dict[str, SnubaTSResult]): + def flatten_results( + self, results: SnubaTSResult | dict[str, SnubaTSResult] + ) -> list[dict[str, Any]]: if isinstance(results, SnubaTSResult): return results.data["data"] else: @@ -101,7 +103,7 @@ def flatten_results(self, results: SnubaTSResult | dict[str, SnubaTSResult]): [], ) - def check_if_results_have_data(self, results: SnubaTSResult | dict[str, SnubaTSResult]): + def check_if_results_have_data(self, results: SnubaTSResult | dict[str, SnubaTSResult]) -> bool: flattened_data = self.flatten_results(results) has_data = any( any( @@ -242,7 +244,7 @@ def _get_event_stats( if should_upsample: final_columns = transform_query_columns_for_error_upsampling(query_columns) - def get_rpc_config(): + def get_rpc_config() -> SearchResolverConfig: if scoped_dataset not in RPC_DATASETS: raise NotImplementedError @@ -373,7 +375,12 @@ def get_rpc_config(): transform_alias_to_input_format=transform_alias_to_input_format, ) - def get_event_stats_factory(scoped_dataset): + def get_event_stats_factory( + scoped_dataset: Any, + ) -> Callable[ + [list[str], str, SnubaParams, int, bool, timedelta | None], + SnubaTSResult | dict[str, SnubaTSResult], + ]: """ This factory closes over dataset in order to make an additional request to the errors dataset in the case that this request is from a dashboard widget and we're trying to split their discover dataset. diff --git a/src/sentry/api/serializers/rest_framework/release.py b/src/sentry/api/serializers/rest_framework/release.py index c19925689fea..bf0f78aae899 100644 --- a/src/sentry/api/serializers/rest_framework/release.py +++ b/src/sentry/api/serializers/rest_framework/release.py @@ -5,6 +5,8 @@ from sentry.constants import COMMIT_RANGE_DELIMITER, MAX_COMMIT_LENGTH, MAX_VERSION_LENGTH from sentry.models.organizationmember import OrganizationMember from sentry.models.release import Release, ReleaseStatus +from sentry.users.models.user import User +from sentry.users.services.user import RpcUser class ReleaseHeadCommitSerializerDeprecated(serializers.Serializer): @@ -22,7 +24,7 @@ class ReleaseHeadCommitSerializer(serializers.Serializer): max_length=MAX_COMMIT_LENGTH, required=False, allow_null=True, allow_blank=True ) - def validate_commit(self, value): + def validate_commit(self, value: str) -> str: """ Value can be either a single commit or a commit range (1a2b3c..6f5e4d) """ @@ -82,7 +84,7 @@ class ReleaseSerializer(serializers.Serializer): help_text="The status of the release. Can be `open` or `archived`.", ) - def validate_status(self, value): + def validate_status(self, value: str) -> int: try: return ReleaseStatus.from_string(value) except ValueError: @@ -101,12 +103,12 @@ class ReleaseWithVersionSerializer(ReleaseSerializer): help_text="The username of the user to set as the release owner.", ) - def validate_version(self, value): + def validate_version(self, value: str) -> str: if not Release.is_valid_version(value): raise serializers.ValidationError("Release with name %s is not allowed" % value) return value - def validate_owner(self, owner): + def validate_owner(self, owner: User | RpcUser) -> User | RpcUser: if not OrganizationMember.objects.filter( organization=self.context["organization"], user_id=owner.id ).exists(): diff --git a/src/sentry/event_manager.py b/src/sentry/event_manager.py index 453d3379f09b..b7c6aec932a0 100644 --- a/src/sentry/event_manager.py +++ b/src/sentry/event_manager.py @@ -1920,15 +1920,18 @@ def _handle_regression( return is_regression -def _is_placeholder_title(title): +def _is_placeholder_title(title: str | None) -> bool: return title in PLACEHOLDER_EVENT_TITLES -def _is_real_title(title): +def _is_real_title(title: str | None) -> bool: return bool(title) and title not in PLACEHOLDER_EVENT_TITLES -def _get_updated_group_title(existing_container, incoming_container): +def _get_updated_group_title( + existing_container: Mapping[str, str | None], + incoming_container: Mapping[str, str | None], +) -> str | None: """ Given either `group.data` or `group.data["metadata"]`, in both existing and incoming forms, pick the correct title to use when updating the group. Uses the incoming title (or `None` if there @@ -2213,7 +2216,7 @@ def _get_priority_for_group(severity: Mapping[str, Any], kwargs: Mapping[str, An return PriorityLevel.MEDIUM -def update_severity_error_count(reset=False) -> None: +def update_severity_error_count(reset: bool = False) -> None: timeout = 60 * 60 # 1 hour if reset: cache.set(SEER_ERROR_COUNT_KEY, 0, timeout=timeout) diff --git a/src/sentry/integrations/github/client.py b/src/sentry/integrations/github/client.py index ffa31a5342eb..fb9589e85a13 100644 --- a/src/sentry/integrations/github/client.py +++ b/src/sentry/integrations/github/client.py @@ -3,7 +3,7 @@ import contextlib import logging import time -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence from datetime import datetime, timedelta from enum import StrEnum from functools import cached_property @@ -255,7 +255,7 @@ def get_user_info(self) -> dict[str, Any]: """ return self.get("/user", api_request_type=GitHubApiRequestType.GET_USER_INFO) - def get_user_info_installations(self): + def get_user_info_installations(self) -> dict[str, Any]: """ Authentication: Access Token Docs: https://docs.github.com/en/rest/apps/installations?apiVersion=2022-11-28#list-app-installations-accessible-to-the-user-access-token @@ -265,7 +265,7 @@ def get_user_info_installations(self): api_request_type=GitHubApiRequestType.GET_USER_INFO_INSTALLATIONS, ) - def get_organization_memberships_for_user(self): + def get_organization_memberships_for_user(self) -> list[dict[str, Any]]: """ Authentication: Access Token Docs: https://docs.github.com/en/rest/orgs/members?apiVersion=2022-11-28#get-an-organization-membership-for-the-authenticated-user @@ -279,7 +279,7 @@ def get_organization_memberships_for_user(self): class GithubProxyClient(IntegrationProxyClient): integration: Integration | RpcIntegration # late init - def request(self, *args: Any, **kwargs: Any): + def request(self, *args: Any, **kwargs: Any) -> Any: credentials_set = kwargs.pop("credentials_set", None) if credentials_set == "application": if "headers" not in kwargs: @@ -1550,7 +1550,7 @@ def __init__( ) @contextlib.contextmanager - def referrer(self, referrer: str): + def referrer(self, referrer: str) -> Iterator[None]: prev = self.__referrer self.__referrer = referrer try: diff --git a/src/sentry/lang/java/exceptions.py b/src/sentry/lang/java/exceptions.py index faebbf694cf3..0da8d744594f 100644 --- a/src/sentry/lang/java/exceptions.py +++ b/src/sentry/lang/java/exceptions.py @@ -62,10 +62,10 @@ def __init__(self, data: Any): if class_matches: self._processable_exceptions_with_values.append((exc, class_matches)) - def get_processable_exceptions(self): + def get_processable_exceptions(self) -> list[dict[str, Any]]: return self._processable_exceptions - def get_exception_class_names(self): + def get_exception_class_names(self) -> list[str]: """ Returns a flattened list of all class names found in the exception values. """ @@ -75,12 +75,12 @@ def get_exception_class_names(self): for class_name in class_names ] - def get_processable_exceptions_with_values(self): + def get_processable_exceptions_with_values(self) -> list[tuple[Any, list[Any]]]: return self._processable_exceptions_with_values def deobfuscate_and_save( self, classes: dict[str, str] | None, mapped_exceptions: list[dict[str, Any]] - ): + ) -> None: """ Deobfuscates all exception values, module and type in-place """ diff --git a/src/sentry/models/rule.py b/src/sentry/models/rule.py index 3576b220c019..09fda51adaae 100644 --- a/src/sentry/models/rule.py +++ b/src/sentry/models/rule.py @@ -93,7 +93,7 @@ class Meta: __repr__ = sane_repr("project_id", "label") @classmethod - def get_for_project(cls, project_id): + def get_for_project(cls, project_id: int) -> list[Rule]: cache_key = f"project:{project_id}:rules" rules_list = cache.get(cache_key) if rules_list is None: @@ -116,12 +116,12 @@ def owner(self, actor: Actor | None) -> None: if actor and actor.is_team: self.owner_team_id = actor.id - def delete(self, *args, **kwargs): + def delete(self, *args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]: rv = super().delete(*args, **kwargs) self._clear_project_rule_cache() return rv - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: rv = super().save(*args, **kwargs) self._clear_project_rule_cache() return rv @@ -130,7 +130,7 @@ def _clear_project_rule_cache(self) -> None: cache_key = f"project:{self.project_id}:rules" cache.delete(cache_key) - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, Any]: return { "label": self.label, "data": self.data, diff --git a/src/sentry/notifications/notificationcontroller.py b/src/sentry/notifications/notificationcontroller.py index 6a878f322348..65774ab3f9d0 100644 --- a/src/sentry/notifications/notificationcontroller.py +++ b/src/sentry/notifications/notificationcontroller.py @@ -154,14 +154,14 @@ def _get_query(self) -> Q: return project_settings | org_settings | user_settings | team_settings - def _filter_options(self, **kwargs) -> list[NotificationSettingOption]: + def _filter_options(self, **kwargs: object) -> list[NotificationSettingOption]: return [ setting for setting in self.get_all_setting_options if all(getattr(setting, arg) == kwargs[arg] for arg in kwargs) ] - def _filter_providers(self, **kwargs) -> list[NotificationSettingProvider]: + def _filter_providers(self, **kwargs: object) -> list[NotificationSettingProvider]: return [ setting for setting in self.get_all_setting_providers @@ -171,7 +171,7 @@ def _filter_providers(self, **kwargs) -> list[NotificationSettingProvider]: def _get_layered_setting_options( self, project_id: int | None = None, - **kwargs, + **kwargs: object, ) -> MutableMapping[ Recipient, MutableMapping[NotificationSettingEnum, NotificationSettingsOptionEnum] ]: @@ -228,7 +228,7 @@ def _get_layered_setting_options( def _get_layered_setting_providers( self, project_id: int | None = None, - **kwargs, + **kwargs: object, ) -> MutableMapping[ Recipient, MutableMapping[ diff --git a/src/sentry/tasks/process_buffer.py b/src/sentry/tasks/process_buffer.py index 29f889716bec..552748dcb971 100644 --- a/src/sentry/tasks/process_buffer.py +++ b/src/sentry/tasks/process_buffer.py @@ -49,8 +49,8 @@ def process_incr( extra: dict[str, Any] | None = None, signal_only: bool | None = None, model_name: str | None = None, - **kwargs, -): + **kwargs: Any, +) -> None: """ Processes a buffer event. """ @@ -75,7 +75,7 @@ def process_incr( ) -def buffer_incr(model: type[Model], *args, **kwargs): +def buffer_incr(model: type[Model], *args: Any, **kwargs: Any) -> None: from sentry import buffer sentry_sdk.set_tag("model", model._meta.model_name) diff --git a/src/sentry/uptime/models.py b/src/sentry/uptime/models.py index 71c175c22a78..6021e223301e 100644 --- a/src/sentry/uptime/models.py +++ b/src/sentry/uptime/models.py @@ -1,7 +1,7 @@ import enum import logging from datetime import timedelta -from typing import ClassVar, Literal, Self, cast, override +from typing import Any, ClassVar, Literal, Self, cast, override from django.db import models from django.db.models import Count, Value @@ -234,7 +234,7 @@ def bulk_get_query_object( @override @staticmethod - def related_model(instance) -> list[ModelRelation]: + def related_model(instance: DataSource) -> list[ModelRelation]: return [ModelRelation(UptimeSubscription, {"id": instance.source_id})] @override @@ -254,7 +254,9 @@ def get_relocation_model_name() -> str: return "uptime.uptimesubscription" -def get_detector(uptime_subscription: UptimeSubscription, prefetch_workflow_data=False) -> Detector: +def get_detector( + uptime_subscription: UptimeSubscription, prefetch_workflow_data: bool = False +) -> Detector: """ Fetches a workflow_engine Detector given an existing uptime_subscription. This is used during the transition period moving uptime to detector. @@ -283,7 +285,7 @@ def get_uptime_subscription(detector: Detector) -> UptimeSubscription: return UptimeSubscription.objects.get_from_cache(id=int(data_source.source_id)) -def get_audit_log_data(detector: Detector): +def get_audit_log_data(detector: Detector) -> dict[str, object]: """Get audit log data from a detector.""" uptime_subscription = get_uptime_subscription(detector) @@ -335,7 +337,7 @@ class Meta: models.Index(fields=["date_added"]), ] - def delete(self, *args, **kwargs): + def delete(self, *args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]: try: File.objects.get(pk=self.file_id).delete() except File.DoesNotExist: From abb3ab5477c08eeccabadc0fcbc597d89a08b637 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:51:23 -0700 Subject: [PATCH 039/103] ref(typing): remove lane 7 and 9 weaklist entries Integrate the two lane batches and remove their 18 entries. Widen the id-or-slug lookup to accept both forms, mirror the nullable team role, and fix the caller typing exposed by the stricter signatures. --- pyproject.toml | 18 ------------------ .../models/organization_member/utils.py | 6 +++--- src/sentry/db/models/fields/slug.py | 2 +- .../data_forwarding/splunk/forwarder.py | 2 +- src/sentry/search/events/builder/metrics.py | 5 +++-- src/sentry/services/eventstore/models.py | 2 +- tests/sentry/backup/test_sanitize.py | 5 ++--- 7 files changed, 11 insertions(+), 29 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dc7aba4f4cca..d433f27007b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -454,7 +454,6 @@ module = [ "sentry.api.authentication", "sentry.api.base", "sentry.api.client", - "sentry.api.endpoints.accept_organization_invite", "sentry.api.endpoints.auth_config", "sentry.api.endpoints.auth_index", "sentry.api.endpoints.broadcast_index", @@ -462,7 +461,6 @@ module = [ "sentry.api.endpoints.chunk", "sentry.api.endpoints.debug_files", "sentry.api.endpoints.organization_events", - "sentry.api.endpoints.organization_events_stats", "sentry.api.endpoints.organization_missing_org_members", "sentry.api.endpoints.organization_profiling_functions", "sentry.api.endpoints.organization_releases", @@ -474,7 +472,6 @@ module = [ "sentry.api.endpoints.organization_unsubscribe", "sentry.api.endpoints.project_artifact_bundle_files", "sentry.api.endpoints.project_performance_general_settings", - "sentry.api.endpoints.project_servicehooks", "sentry.api.endpoints.project_trace_item_details", "sentry.api.endpoints.project_transaction_threshold", "sentry.api.endpoints.project_transaction_threshold_override", @@ -492,7 +489,6 @@ module = [ "sentry.api.serializers.models.userrollback", "sentry.api.serializers.rest_framework.base", "sentry.api.serializers.rest_framework.dashboard", - "sentry.api.serializers.rest_framework.release", "sentry.api.serializers.snuba", "sentry.api.validators.*", "sentry.apidocs.hooks", @@ -513,7 +509,6 @@ module = [ "sentry.db.models.fields.gzippeddict", "sentry.db.models.fields.jsonfield", "sentry.db.models.fields.node", - "sentry.db.models.fields.slug", "sentry.db.models.fields.text", "sentry.db.models.fields.uuid", "sentry.db.postgres.*", @@ -525,7 +520,6 @@ module = [ "sentry.discover.endpoints.*", "sentry.discover.models", "sentry.discover.translation.*", - "sentry.event_manager", "sentry.explore.endpoints.*", "sentry.explore.translation.*", "sentry.features.*", @@ -555,7 +549,6 @@ module = [ "sentry.integrations.api.endpoints.data_forwarding_index", "sentry.integrations.api.endpoints.doc_integrations_index", "sentry.integrations.api.endpoints.integration_proxy", - "sentry.integrations.api.endpoints.organization_code_mapping_codeowners", "sentry.integrations.api.endpoints.organization_code_mappings", "sentry.integrations.api.endpoints.organization_repository_settings", "sentry.integrations.api.serializers.*", @@ -568,7 +561,6 @@ module = [ "sentry.integrations.discord.client", "sentry.integrations.discord.requests.*", "sentry.integrations.example.*", - "sentry.integrations.github.client", "sentry.integrations.github.installation", "sentry.integrations.github.integration", "sentry.integrations.github.tasks.*", @@ -588,7 +580,6 @@ module = [ "sentry.integrations.messaging.*", "sentry.integrations.middleware.*", "sentry.integrations.mixins.*", - "sentry.integrations.models.external_actor", "sentry.integrations.models.integration", "sentry.integrations.msteams.actions.*", "sentry.integrations.msteams.card_builder.*", @@ -619,14 +610,12 @@ module = [ "sentry.issues.issue_search", "sentry.killswitches", "sentry.lang.dart.*", - "sentry.lang.java.exceptions", "sentry.lang.java.view_hierarchies", "sentry.lang.javascript.*", "sentry.lang.native.*", "sentry.loader.*", "sentry.logging.*", "sentry.management.*", - "sentry.middleware.customer_domain", "sentry.middleware.devtoolbar", "sentry.models.artifactbundle", "sentry.models.auditlogentry", @@ -659,8 +648,6 @@ module = [ "sentry.models.release_threshold.*", "sentry.models.releases.*", "sentry.models.repository", - "sentry.models.rule", - "sentry.models.sourcemapprocessingissue", "sentry.models.team", "sentry.models.transaction_threshold", "sentry.monitors.endpoints.*", @@ -679,7 +666,6 @@ module = [ "sentry.notifications.api.endpoints.*", "sentry.notifications.api.serializers.*", "sentry.notifications.models.*", - "sentry.notifications.notificationcontroller", "sentry.onboarding_tasks.*", "sentry.options.manager", "sentry.options.store", @@ -744,7 +730,6 @@ module = [ "sentry.snuba.outcomes", "sentry.snuba.sessions_v2", "sentry.snuba.spans_metrics", - "sentry.spans.buffer", "sentry.spans.consumers.process.*", "sentry.spans.log_analyzer.*", "sentry.stacktraces.functions", @@ -760,9 +745,7 @@ module = [ "sentry.tasks.embeddings_grouping.*", "sentry.tasks.files", "sentry.tasks.groupowner", - "sentry.tasks.process_buffer", "sentry.tasks.relay", - "sentry.tasks.release_registry", "sentry.tasks.seer.agentic_triage.*", "sentry.tasks.seer.backfill_supergroups_lightweight", "sentry.tasks.seer.context_engine_index", @@ -797,7 +780,6 @@ module = [ "sentry.uptime.autodetect.*", "sentry.uptime.consumers.*", "sentry.uptime.endpoints.*", - "sentry.uptime.models", "sentry.uptime.subscriptions.*", "sentry.utils.arroyo_producer", "sentry.utils.concurrent", diff --git a/src/sentry/api/serializers/models/organization_member/utils.py b/src/sentry/api/serializers/models/organization_member/utils.py index d51d134fbdaf..24d8ef004bca 100644 --- a/src/sentry/api/serializers/models/organization_member/utils.py +++ b/src/sentry/api/serializers/models/organization_member/utils.py @@ -9,7 +9,7 @@ TeamData = TypeVar("TeamData") DictOfMembers = dict[Any, list[TeamData]] MembersTeams = DictOfMembers[str] -MembersTeamRoles = DictOfMembers[dict[str, str]] +MembersTeamRoles = DictOfMembers[dict[str, str | None]] def get_teams_by_organization_member_id( @@ -25,8 +25,8 @@ def get_teams_by_organization_member_id( teams = Team.objects.filter(id__in=team_ids) teams_by_id = {team.id: team for team in teams} - result_teams = defaultdict(list) - result_teams_with_roles = defaultdict(list) + result_teams: MembersTeams = defaultdict(list) + result_teams_with_roles: MembersTeamRoles = defaultdict(list) for member_id, team_id, role in organization_member_tuples: teamSlug = teams_by_id[team_id].slug result_teams[member_id].append(teamSlug) # Deprecated diff --git a/src/sentry/db/models/fields/slug.py b/src/sentry/db/models/fields/slug.py index af780bf4fdbb..a9b7753c8f24 100644 --- a/src/sentry/db/models/fields/slug.py +++ b/src/sentry/db/models/fields/slug.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: _SlugField = SlugField[str, str] - _Lookup = Lookup[str] + _Lookup = Lookup[str | int] else: _SlugField = SlugField _Lookup = Lookup diff --git a/src/sentry/integrations/data_forwarding/splunk/forwarder.py b/src/sentry/integrations/data_forwarding/splunk/forwarder.py index 069225611976..ad2dbe28e582 100644 --- a/src/sentry/integrations/data_forwarding/splunk/forwarder.py +++ b/src/sentry/integrations/data_forwarding/splunk/forwarder.py @@ -76,7 +76,7 @@ def get_rl_key(self, event: Event | GroupEvent) -> str: return f"{self.provider.value}:{md5_text(self.project_token).hexdigest()}" def get_event_payload_properties(self, event: Event | GroupEvent) -> dict[str, Any]: - props = { + props: dict[str, object] = { "event_id": event.event_id, "issue_id": event.group_id, "project_id": event.project.slug, diff --git a/src/sentry/search/events/builder/metrics.py b/src/sentry/search/events/builder/metrics.py index fdfdc051dc47..6513e442e25a 100644 --- a/src/sentry/search/events/builder/metrics.py +++ b/src/sentry/search/events/builder/metrics.py @@ -3,7 +3,7 @@ from collections import defaultdict from collections.abc import Mapping, Sequence from datetime import datetime -from typing import Any +from typing import Any, cast import sentry_sdk from django.contrib.auth.models import AnonymousUser @@ -1871,7 +1871,8 @@ def resolve_top_event_conditions( if isinstance(value, list): value = {project_map.get(val) for val in value} else: - value = project_map.get(value) + # justification: Snuba returns the project slug as a string here. + value = project_map.get(cast(str, value)) # TODO: Handle potential None case elif value is not None: value = self.resolve_tag_value(str(value)) diff --git a/src/sentry/services/eventstore/models.py b/src/sentry/services/eventstore/models.py index 597c54a160a5..08856fbc08ae 100644 --- a/src/sentry/services/eventstore/models.py +++ b/src/sentry/services/eventstore/models.py @@ -839,7 +839,7 @@ def __getitem__(self, name: str) -> str: elif name == "project": return cast(str, self.event.project.get_full_name()) elif name == "projectID": - return cast(str, self.event.project.slug) + return self.event.project.slug elif name == "shortID" and self.event.group_id and self.event.group: return cast(str, self.event.group.qualified_short_id) elif name == "orgID": diff --git a/tests/sentry/backup/test_sanitize.py b/tests/sentry/backup/test_sanitize.py index 515066bb563d..aee3e6765e67 100644 --- a/tests/sentry/backup/test_sanitize.py +++ b/tests/sentry/backup/test_sanitize.py @@ -23,7 +23,6 @@ from sentry.backup.scopes import RelocationScope from sentry.db.models.base import DefaultFieldsModel from sentry.db.models.fields.jsonfield import JSONField -from sentry.db.models.fields.slug import SentrySlugField from sentry.db.models.fields.uuid import UUIDField FAKE_EMAIL = "test@fake.com" @@ -52,9 +51,9 @@ class FakeSanitizableModel(DefaultFieldsModel): email = models.EmailField(null=True, max_length=75) name = models.CharField(null=True, max_length=64) - slug = SentrySlugField(null=True) + slug = models.SlugField(null=True) nickname = models.CharField(null=True, max_length=32) - text = SentrySlugField(null=True, max_length=128) + text = models.SlugField(null=True, max_length=128) json_dict = JSONField(null=True, default=dict) json_list = JSONField(null=True, default=list) ip_v4 = models.GenericIPAddressField(null=True) From 0b49e4d7f89244b3dbeb953cdc76eb0d0ebbc096 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:58:11 -0700 Subject: [PATCH 040/103] fix(typing): replace lane 9 Any with precise contracts Give the GitHub setup responses named shapes, the processable JVM exceptions a typed dict, and the buffer task the backend's parameter types. The remaining dynamic boundaries carry justification comments. --- .../endpoints/organization_events_stats.py | 4 +- src/sentry/integrations/github/client.py | 57 +++++++++++++++---- src/sentry/lang/java/exceptions.py | 31 ++++++---- src/sentry/models/rule.py | 28 ++++++--- src/sentry/tasks/process_buffer.py | 23 ++++++-- src/sentry/uptime/models.py | 8 ++- tests/sentry/lang/java/test_exceptions.py | 6 +- tests/sentry/tasks/test_process_buffer.py | 2 +- 8 files changed, 117 insertions(+), 42 deletions(-) diff --git a/src/sentry/api/endpoints/organization_events_stats.py b/src/sentry/api/endpoints/organization_events_stats.py index 0b1ba458f234..f997df12d73b 100644 --- a/src/sentry/api/endpoints/organization_events_stats.py +++ b/src/sentry/api/endpoints/organization_events_stats.py @@ -94,7 +94,7 @@ def get_features( def flatten_results( self, results: SnubaTSResult | dict[str, SnubaTSResult] - ) -> list[dict[str, Any]]: + ) -> list[dict[str, object]]: if isinstance(results, SnubaTSResult): return results.data["data"] else: @@ -376,7 +376,7 @@ def get_rpc_config() -> SearchResolverConfig: ) def get_event_stats_factory( - scoped_dataset: Any, + scoped_dataset: Any, # justification: dataset modules expose different query functions. ) -> Callable[ [list[str], str, SnubaParams, int, bool, timedelta | None], SnubaTSResult | dict[str, SnubaTSResult], diff --git a/src/sentry/integrations/github/client.py b/src/sentry/integrations/github/client.py index fb9589e85a13..d8f65cad767b 100644 --- a/src/sentry/integrations/github/client.py +++ b/src/sentry/integrations/github/client.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta from enum import StrEnum from functools import cached_property -from typing import Any, TypedDict +from typing import Any, TypedDict, cast import orjson import sentry_sdk @@ -212,6 +212,31 @@ class GitHubApiRequestType(StrEnum): UPDATE_ISSUE_STATUS = "update_issue_status" +class GithubInstallationAccount(TypedDict): + login: str + avatar_url: str + + +class GithubUserInstallation(TypedDict): + id: int + account: GithubInstallationAccount + target_type: str + + +class GithubUserInstallations(TypedDict): + installations: list[GithubUserInstallation] + + +class GithubUserOrganization(TypedDict): + login: str + + +class GithubUserOrganizationMembership(TypedDict): + organization: GithubUserOrganization + role: str + state: str + + class GithubSetupApiClient(IntegrationProxyClient): """ API Client that doesn't require an installation. @@ -255,31 +280,43 @@ def get_user_info(self) -> dict[str, Any]: """ return self.get("/user", api_request_type=GitHubApiRequestType.GET_USER_INFO) - def get_user_info_installations(self) -> dict[str, Any]: + def get_user_info_installations(self) -> GithubUserInstallations: """ Authentication: Access Token Docs: https://docs.github.com/en/rest/apps/installations?apiVersion=2022-11-28#list-app-installations-accessible-to-the-user-access-token """ - return self.get( - "/user/installations", - api_request_type=GitHubApiRequestType.GET_USER_INFO_INSTALLATIONS, + # justification: GitHub's REST API defines this response shape. + return cast( + GithubUserInstallations, + self.get( + "/user/installations", + api_request_type=GitHubApiRequestType.GET_USER_INFO_INSTALLATIONS, + ), ) - def get_organization_memberships_for_user(self) -> list[dict[str, Any]]: + def get_organization_memberships_for_user(self) -> list[GithubUserOrganizationMembership]: """ Authentication: Access Token Docs: https://docs.github.com/en/rest/orgs/members?apiVersion=2022-11-28#get-an-organization-membership-for-the-authenticated-user """ - return self.get( - "/user/memberships/orgs", - api_request_type=GitHubApiRequestType.GET_ORGANIZATION_MEMBERSHIPS_FOR_USER, + # justification: GitHub's REST API defines this response shape. + return cast( + list[GithubUserOrganizationMembership], + self.get( + "/user/memberships/orgs", + api_request_type=GitHubApiRequestType.GET_ORGANIZATION_MEMBERSHIPS_FOR_USER, + ), ) class GithubProxyClient(IntegrationProxyClient): integration: Integration | RpcIntegration # late init - def request(self, *args: Any, **kwargs: Any) -> Any: + def request( + self, + *args: Any, + **kwargs: Any, # justification: proxy forwards arbitrary API requests. + ) -> Any: credentials_set = kwargs.pop("credentials_set", None) if credentials_set == "application": if "headers" not in kwargs: diff --git a/src/sentry/lang/java/exceptions.py b/src/sentry/lang/java/exceptions.py index 0da8d744594f..b7508cabf698 100644 --- a/src/sentry/lang/java/exceptions.py +++ b/src/sentry/lang/java/exceptions.py @@ -1,6 +1,6 @@ import re -from collections.abc import Mapping -from typing import Any +from collections.abc import Mapping, Sequence +from typing import Any, NotRequired, TypedDict from sentry.utils.safe import get_path @@ -43,10 +43,19 @@ ) +class ProcessableException(TypedDict): + type: str + value: NotRequired[str] + module: NotRequired[str | None] + raw_module: NotRequired[str | None] + raw_type: NotRequired[str | None] + raw_value: NotRequired[str] + + class Exceptions: def __init__(self, data: Any): - self._processable_exceptions: list[dict[str, Any]] = [] - self._processable_exceptions_with_values: list[tuple[Any, list[Any]]] = [] + self._processable_exceptions: list[ProcessableException] = [] + self._processable_exceptions_with_values: list[tuple[ProcessableException, list[str]]] = [] for exc in get_path(data, "exception", "values", filter=True, default=()): if exc.get("type", None): @@ -62,7 +71,7 @@ def __init__(self, data: Any): if class_matches: self._processable_exceptions_with_values.append((exc, class_matches)) - def get_processable_exceptions(self) -> list[dict[str, Any]]: + def get_processable_exceptions(self) -> list[ProcessableException]: return self._processable_exceptions def get_exception_class_names(self) -> list[str]: @@ -75,21 +84,23 @@ def get_exception_class_names(self) -> list[str]: for class_name in class_names ] - def get_processable_exceptions_with_values(self) -> list[tuple[Any, list[Any]]]: + def get_processable_exceptions_with_values( + self, + ) -> list[tuple[ProcessableException, list[str]]]: return self._processable_exceptions_with_values def deobfuscate_and_save( - self, classes: dict[str, str] | None, mapped_exceptions: list[dict[str, Any]] + self, classes: dict[str, str] | None, mapped_exceptions: Sequence[Mapping[str, str]] ) -> None: """ Deobfuscates all exception values, module and type in-place """ # Deobfuscate exception module and type first - for raw_exc, exc in zip(self._processable_exceptions, mapped_exceptions): + for raw_exc, mapped_exc in zip(self._processable_exceptions, mapped_exceptions): raw_exc["raw_module"] = raw_exc.get("module") raw_exc["raw_type"] = raw_exc.get("type") - raw_exc["module"] = exc["module"] - raw_exc["type"] = exc["type"] + raw_exc["module"] = mapped_exc["module"] + raw_exc["type"] = mapped_exc["type"] # Deobfuscate exception values # Note: replace each candidate where it was matched rather than by searching for its diff --git a/src/sentry/models/rule.py b/src/sentry/models/rule.py index 09fda51adaae..a627d6ebcb57 100644 --- a/src/sentry/models/rule.py +++ b/src/sentry/models/rule.py @@ -1,11 +1,12 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Iterable, Sequence from enum import IntEnum from typing import Any, ClassVar, Self from django.conf import settings from django.db import models +from django.db.models.base import ModelBase from django.utils import timezone from sentry.backup.scopes import RelocationScope @@ -116,21 +117,34 @@ def owner(self, actor: Actor | None) -> None: if actor and actor.is_team: self.owner_team_id = actor.id - def delete(self, *args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]: - rv = super().delete(*args, **kwargs) + def delete( + self, using: str | None = None, keep_parents: bool = False + ) -> tuple[int, dict[str, int]]: + rv = super().delete(using=using, keep_parents=keep_parents) self._clear_project_rule_cache() return rv - def save(self, *args: Any, **kwargs: Any) -> None: - rv = super().save(*args, **kwargs) + def save( + self, + *, + force_insert: bool | tuple[ModelBase, ...] = False, + force_update: bool = False, + using: str | None = None, + update_fields: Iterable[str] | None = None, + ) -> None: + super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) self._clear_project_rule_cache() - return rv def _clear_project_rule_cache(self) -> None: cache_key = f"project:{self.project_id}:rules" cache.delete(cache_key) - def get_audit_log_data(self) -> dict[str, Any]: + def get_audit_log_data(self) -> dict[str, object]: return { "label": self.label, "data": self.data, diff --git a/src/sentry/tasks/process_buffer.py b/src/sentry/tasks/process_buffer.py index 552748dcb971..58b3f67bd316 100644 --- a/src/sentry/tasks/process_buffer.py +++ b/src/sentry/tasks/process_buffer.py @@ -1,5 +1,7 @@ +from __future__ import annotations + import logging -from typing import Any +from typing import TYPE_CHECKING import sentry_sdk from django.apps import apps @@ -10,6 +12,9 @@ from sentry.utils.locking import UnableToAcquireLock from sentry.utils.locking.lock import Lock +if TYPE_CHECKING: + from sentry.buffer.base import BufferField + logger = logging.getLogger(__name__) @@ -45,11 +50,11 @@ def process_pending() -> None: ) def process_incr( columns: dict[str, int] | None = None, - filters: dict[str, Any] | None = None, - extra: dict[str, Any] | None = None, + filters: dict[str, object] | None = None, + extra: dict[str, object] | None = None, signal_only: bool | None = None, model_name: str | None = None, - **kwargs: Any, + **kwargs: object, ) -> None: """ Processes a buffer event. @@ -75,10 +80,16 @@ def process_incr( ) -def buffer_incr(model: type[Model], *args: Any, **kwargs: Any) -> None: +def buffer_incr( + model: type[Model], + columns: dict[str, int], + filters: dict[str, BufferField], + extra: dict[str, object] | None = None, + signal_only: bool | None = None, +) -> None: from sentry import buffer sentry_sdk.set_tag("model", model._meta.model_name) sentry_sdk.set_attribute("model", model._meta.model_name) - buffer.backend.incr(model, *args, **kwargs) + buffer.backend.incr(model, columns, filters, extra=extra, signal_only=signal_only) diff --git a/src/sentry/uptime/models.py b/src/sentry/uptime/models.py index 6021e223301e..a052b88e0274 100644 --- a/src/sentry/uptime/models.py +++ b/src/sentry/uptime/models.py @@ -1,7 +1,7 @@ import enum import logging from datetime import timedelta -from typing import Any, ClassVar, Literal, Self, cast, override +from typing import ClassVar, Literal, Self, cast, override from django.db import models from django.db.models import Count, Value @@ -337,9 +337,11 @@ class Meta: models.Index(fields=["date_added"]), ] - def delete(self, *args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]: + def delete( + self, using: str | None = None, keep_parents: bool = False + ) -> tuple[int, dict[str, int]]: try: File.objects.get(pk=self.file_id).delete() except File.DoesNotExist: pass - return super().delete(*args, **kwargs) + return super().delete(using=using, keep_parents=keep_parents) diff --git a/tests/sentry/lang/java/test_exceptions.py b/tests/sentry/lang/java/test_exceptions.py index e9c551fde8a1..b41ff972c8a7 100644 --- a/tests/sentry/lang/java/test_exceptions.py +++ b/tests/sentry/lang/java/test_exceptions.py @@ -365,7 +365,7 @@ def test_deobfuscate_is_noop_when_no_classes_mapping() -> None: # Empty mapping excs = Exceptions(build_event([{"value": original}])) - exc = excs.get_processable_exceptions_with_values()[0][0] + processable_exc = excs.get_processable_exceptions_with_values()[0][0] excs.deobfuscate_and_save({}, mapped_exceptions=[]) - assert exc["value"] == original - assert "raw_value" not in exc + assert processable_exc["value"] == original + assert "raw_value" not in processable_exc diff --git a/tests/sentry/tasks/test_process_buffer.py b/tests/sentry/tasks/test_process_buffer.py index 13e51ae8090c..308d52ea79ef 100644 --- a/tests/sentry/tasks/test_process_buffer.py +++ b/tests/sentry/tasks/test_process_buffer.py @@ -16,7 +16,7 @@ def test_constraints_model_name(self) -> None: @mock.patch("sentry.buffer.backend.process") def test_calls_process_with_model_name(self, process: mock.MagicMock) -> None: columns = {"times_seen": 1} - filters = {"pk": 1} + filters: dict[str, object] = {"pk": 1} process_incr(model_name="sentry.Group", columns=columns, filters=filters) process.assert_called_once_with( model=Group, columns=columns, filters=filters, extra=None, signal_only=None From 8d4ff23b5bf56dae7875876938821e7370fb114d Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 12:47:39 -0700 Subject: [PATCH 041/103] ref(typing): graduate lane 8 weaklist modules Annotate the lane 8 modules so they pass strict mypy with their weaklist entries removed. The transaction-threshold filter selects dynamic columns, so its rows stay object-typed and the metrics extraction consumer narrows the metric it reads. --- src/sentry/api/endpoints/auth_config.py | 10 +-- .../api/serializers/models/artifactbundle.py | 68 ++++++++++++++----- src/sentry/discover/models.py | 11 +-- src/sentry/integrations/discord/client.py | 8 +-- src/sentry/integrations/slack/sdk_client.py | 9 +-- src/sentry/models/counter.py | 19 ++++-- src/sentry/models/transaction_threshold.py | 36 ++++++++-- src/sentry/snuba/metrics/extraction.py | 2 +- src/sentry/tasks/groupowner.py | 28 ++++---- src/sentry/testutils/requests.py | 21 ++++-- 10 files changed, 150 insertions(+), 62 deletions(-) diff --git a/src/sentry/api/endpoints/auth_config.py b/src/sentry/api/endpoints/auth_config.py index 9a557dce5602..1850b4b8cbcf 100644 --- a/src/sentry/api/endpoints/auth_config.py +++ b/src/sentry/api/endpoints/auth_config.py @@ -53,11 +53,11 @@ class AuthConfigEndpoint(Endpoint, OrganizationMixin): # Disable authentication and permission requirements. permission_classes = () - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponseBase: self.active_organization = determine_active_organization(request) return super().dispatch(request, *args, **kwargs) - def get(self, request: Request, *args, **kwargs) -> Response: + def get(self, request: Request, *args: object, **kwargs: object) -> Response: """ Get context required to show a login page. Registration is handled elsewhere. """ @@ -97,7 +97,7 @@ def respond_with_login_context(self, request: Request, payload: AuthConfigRespon return response - def respond_authenticated(self, request: Request): + def respond_authenticated(self, request: Request) -> Response: next_uri = self.get_next_uri(request, consume=False) if not is_valid_redirect(next_uri, allowed_hosts=(request.get_host(),)): @@ -113,7 +113,9 @@ def get_next_uri(self, request: HttpRequest, *, consume: bool = True) -> str: ) return request.GET.get(REDIRECT_FIELD_NAME, next_uri_fallback) or "" - def prepare_login_context(self, request: Request, *args, **kwargs) -> AuthConfigResponse: + def prepare_login_context( + self, request: Request, *args: object, **kwargs: object + ) -> AuthConfigResponse: can_register = bool(has_user_registration() or request.session.get("can_register")) context: dict[str, object] = { diff --git a/src/sentry/api/serializers/models/artifactbundle.py b/src/sentry/api/serializers/models/artifactbundle.py index f35465e70a2f..ef84d4ca8efb 100644 --- a/src/sentry/api/serializers/models/artifactbundle.py +++ b/src/sentry/api/serializers/models/artifactbundle.py @@ -1,13 +1,18 @@ import base64 import uuid +import zipfile from collections.abc import Mapping, Sequence from datetime import datetime -from typing import TypedDict, cast +from typing import Protocol, TypedDict, cast from django.contrib.auth.models import AnonymousUser from sentry.api.serializers import Serializer -from sentry.models.artifactbundle import ReleaseArtifactBundle, SourceFileType +from sentry.models.artifactbundle import ( + ArtifactBundleArchive, + ReleaseArtifactBundle, + SourceFileType, +) from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser @@ -38,6 +43,20 @@ class ArtifactBundleFilesSerializerResponse(TypedDict): sourcemap: str | None +class ArtifactBundleFile(Protocol): + file_path: str + info: Mapping[str, object] + + +class ArtifactBundleFileAttrs(TypedDict): + file_type: SourceFileType | None + file_path: str + file_url: str | None + file_info: zipfile.ZipInfo | None + debug_id: str | None + sourcemap: str | None + + class ArtifactBundlesSerializer(Serializer[ArtifactBundlesSerializerResponse]): @staticmethod def _compute_associations( @@ -100,24 +119,30 @@ def serialize( } -class ArtifactBundleFilesSerializer(Serializer): - def __init__(self, archive, *args, **kwargs): +class ArtifactBundleFilesSerializer(Serializer[ArtifactBundleFilesSerializerResponse]): + def __init__(self, archive: ArtifactBundleArchive, *args: object, **kwargs: object) -> None: Serializer.__init__(self, *args, **kwargs) self.archive = archive - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[ArtifactBundleFile], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[ArtifactBundleFile, ArtifactBundleFileAttrs]: return {item: self._compute_attrs(item) for item in item_list} - def _compute_attrs(self, item): + def _compute_attrs(self, item: ArtifactBundleFile) -> ArtifactBundleFileAttrs: file_path = item.file_path info = item.info - headers = self.archive.normalize_headers(info.get("headers", {})) + headers = self.archive.normalize_headers(cast("dict[str, object]", info.get("headers", {}))) debug_id = self.archive.normalize_debug_id(headers.get("debug-id")) - sourcemap = headers.get("sourcemap") + # justification: manifest header values are JSON strings + sourcemap = cast("str | None", headers.get("sourcemap")) return { - "file_type": SourceFileType.from_lowercase_key(info.get("type")), + "file_type": SourceFileType.from_lowercase_key(cast("str | None", info.get("type"))), "file_path": file_path, "file_url": self.archive.get_file_url_by_file_path(file_path), "file_info": self.archive.get_file_info(file_path), @@ -125,18 +150,27 @@ def _compute_attrs(self, item): "sourcemap": sourcemap, } - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: ArtifactBundleFile, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ArtifactBundleFilesSerializerResponse: + file_attrs = cast(ArtifactBundleFileAttrs, attrs) return { - "id": base64.urlsafe_b64encode(attrs["file_path"].encode()).decode(), + "id": base64.urlsafe_b64encode(file_attrs["file_path"].encode()).decode(), # In case the file type string was invalid, we return the sentinel value INVALID_SOURCE_FILE_TYPE. "fileType": ( - attrs["file_type"].value - if attrs["file_type"] is not None + file_attrs["file_type"].value + if file_attrs["file_type"] is not None else INVALID_SOURCE_FILE_TYPE ), # We decided to return the file url as file path for better searchability. - "filePath": attrs["file_url"], - "fileSize": attrs["file_info"].file_size if attrs["file_info"] is not None else None, - "debugId": attrs["debug_id"], - "sourcemap": attrs["sourcemap"], + "filePath": file_attrs["file_url"], + "fileSize": ( + file_attrs["file_info"].file_size if file_attrs["file_info"] is not None else None + ), + "debugId": file_attrs["debug_id"], + "sourcemap": file_attrs["sourcemap"], } diff --git a/src/sentry/discover/models.py b/src/sentry/discover/models.py index 606af155d136..168f8c59d599 100644 --- a/src/sentry/discover/models.py +++ b/src/sentry/discover/models.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Sequence from enum import Enum from typing import ClassVar @@ -63,7 +64,7 @@ class DatasetSourcesTypes(Enum): FORCED = 3 @classmethod - def as_choices(cls): + def as_choices(cls) -> tuple[tuple[int, str], ...]: return tuple((source.value, source.name.lower()) for source in cls) @@ -129,7 +130,7 @@ class Meta: __repr__ = sane_repr("organization_id", "created_by_id", "name") - def set_projects(self, project_ids): + def set_projects(self, project_ids: Sequence[int]) -> None: with transaction.atomic(router.db_for_write(DiscoverSavedQueryProject)): DiscoverSavedQueryProject.objects.filter(discover_saved_query=self).exclude( project__in=project_ids @@ -151,7 +152,9 @@ def set_projects(self, project_ids): class TeamKeyTransactionModelManager(BaseManager["TeamKeyTransaction"]): @staticmethod - def __schedule_invalidate_project_config_transaction_commit(instance, trigger): + def __schedule_invalidate_project_config_transaction_commit( + instance: TeamKeyTransaction, trigger: str + ) -> None: try: project = getattr(instance.project_team, "project", None) except ProjectTeam.DoesNotExist: @@ -184,7 +187,7 @@ def post_save(self, *, instance: TeamKeyTransaction, created: bool, **kwargs: ob instance, "teamkeytransaction.post_save" ) - def post_delete(self, instance, **kwargs): + def post_delete(self, instance: TeamKeyTransaction, **kwargs: object) -> None: # this hook may be called from model hooks during an # open transaction. In that case, wait until the current transaction has # been committed or rolled back to ensure we don't read stale data in the diff --git a/src/sentry/integrations/discord/client.py b/src/sentry/integrations/discord/client.py index 31945747df40..a4bff8eea1de 100644 --- a/src/sentry/integrations/discord/client.py +++ b/src/sentry/integrations/discord/client.py @@ -52,7 +52,7 @@ class DiscordClient(ApiClient): _METRICS_USER_ERROR_KEY: str = "sentry.integrations.discord.failure.user_error" _METRICS_RATE_LIMIT_KEY: str = "sentry.integrations.discord.failure.rate_limit" - def __init__(self): + def __init__(self) -> None: super().__init__() self.application_id = options.get("discord.application-id") self.client_secret = options.get("discord.client-secret") @@ -79,7 +79,7 @@ def get_guild_name(self, guild_id: str) -> str: response = self.get(GUILD_URL.format(guild_id=guild_id), headers=self.prepare_auth_header()) return response["name"] - def get_access_token(self, code: str, url: str): + def get_access_token(self, code: str, url: str) -> str: data = { "client_id": self.application_id, "client_secret": self.client_secret, @@ -94,7 +94,7 @@ def get_access_token(self, code: str, url: str): response = self.post(TOKEN_URL, json=False, data=urlencode(data), headers=headers) return response["access_token"] - def get_user_id(self, access_token: str): + def get_user_id(self, access_token: str) -> str: headers = {"Authorization": f"Bearer {access_token}"} response = self.get( USER_URL, @@ -180,7 +180,7 @@ def _handle_failure( Do extra logic to handle an error from Discord """ - discord_error_response: dict | None = None + discord_error_response: dict[str, object] | None = None if resp is not None: # Try to get the additional error code that Discord sent us to help determine what specific error happened try: diff --git a/src/sentry/integrations/slack/sdk_client.py b/src/sentry/integrations/slack/sdk_client.py index b1c105cbd2a8..c0307419ef82 100644 --- a/src/sentry/integrations/slack/sdk_client.py +++ b/src/sentry/integrations/slack/sdk_client.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Callable from functools import wraps from types import FunctionType @@ -49,9 +50,9 @@ def is_response_fatal(response: SlackResponse) -> bool: return False -def wrapper(method: FunctionType): +def wrapper(method: FunctionType) -> Callable[..., SlackResponse]: @wraps(method) - def wrapped(*args, **kwargs): + def wrapped(*args: object, **kwargs: object) -> SlackResponse: try: response = method(*args, **kwargs) track_response_data(response=response, method=method.__name__) @@ -74,14 +75,14 @@ def wrapped(*args, **kwargs): return wrapped -def wrap_api_call(cls): +def wrap_api_call(cls: type) -> None: for name, attribute in vars(cls).items(): if isinstance(attribute, FunctionType) and attribute.__name__ in SLACK_SDK_WRAP_METHODS: setattr(cls, name, wrapper(attribute)) class MetaClass(type): - def __new__(meta, name, bases, dct): + def __new__(meta, name: str, bases: tuple[type, ...], dct: dict[str, object]) -> type: cls = super().__new__(meta, name, bases, dct) for parent in cls.__bases__: for base in parent.__bases__: diff --git a/src/sentry/models/counter.py b/src/sentry/models/counter.py index a2a0cbb83fdf..d3588a91ecdf 100644 --- a/src/sentry/models/counter.py +++ b/src/sentry/models/counter.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from django.conf import settings from django.db import connections, transaction from sentry_sdk import traces @@ -18,6 +22,9 @@ from sentry.utils.locking import UnableToAcquireLock from sentry.utils.redis import redis_clusters +if TYPE_CHECKING: + from sentry.models.project import Project + LOW_WATER_RATIO = 0.2 """ If the number of short ids in Redis is less than this ratio, we will refill the block. @@ -41,7 +48,7 @@ class Meta: db_table = "sentry_projectcounter" @classmethod - def increment(cls, project, delta=1) -> int: + def increment(cls, project: Project, delta: int = 1) -> int: """Increments a counter. This can never decrement.""" if delta == 1: # only use the cache path if delta is 1, as in other cases we're trying to resolve @@ -53,7 +60,7 @@ def increment(cls, project, delta=1) -> int: @traces.trace @metrics.wraps("counter.increment_project_counter_in_cache") -def increment_project_counter_in_cache(project, using="default") -> int: +def increment_project_counter_in_cache(project: Project, using: str = "default") -> int: redis_key = make_short_id_counter_key(project.id) redis = redis_clusters.get("default") @@ -86,7 +93,9 @@ def increment_project_counter_in_cache(project, using="default") -> int: @traces.trace @metrics.wraps("counter.increment_project_counter_in_database") -def increment_project_counter_in_database(project, delta=1, using="default") -> int: +def increment_project_counter_in_database( + project: Project, delta: int = 1, using: str = "default" +) -> int: """This method primarily exists so that south code can use it.""" if delta <= 0: raise ValueError("There is only one way, and that's up.") @@ -133,7 +142,9 @@ def increment_project_counter_in_database(project, delta=1, using="default") -> retry=None, # No retries since we want to try again on next counter increment silo_mode=SiloMode.CELL, ) -def refill_cached_short_ids(project_id, block_size: int, using="default", **kwargs) -> None: +def refill_cached_short_ids( + project_id: int, block_size: int, using: str = "default", **kwargs: object +) -> None: """Refills the Redis short-id counter block for a project.""" from sentry.models.project import Project diff --git a/src/sentry/models/transaction_threshold.py b/src/sentry/models/transaction_threshold.py index b3e24bad92f4..cddf078239d9 100644 --- a/src/sentry/models/transaction_threshold.py +++ b/src/sentry/models/transaction_threshold.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +from collections.abc import Sequence from enum import Enum from django.db import models @@ -27,13 +30,26 @@ class TransactionMetric(Enum): PROJECT_TRANSACTION_THRESHOLD_CACHE_TTL = 3600 -def get_project_threshold_cache_key(model_name, project_ids, org_id, order_by, value_list): +def get_project_threshold_cache_key( + model_name: str, + project_ids: Sequence[int], + org_id: int, + order_by: Sequence[str], + value_list: Sequence[str], +) -> str: return "{}:{}".format( model_name, md5_text(f"{project_ids}:{org_id}:{order_by}:{value_list}").hexdigest() ) -def _filter_and_cache(cls, cache_key, project_ids, organization_id, order_by, value_list): +def _filter_and_cache( + cls: type[ProjectTransactionThreshold] | type[ProjectTransactionThresholdOverride], + cache_key: str, + project_ids: Sequence[int], + organization_id: int, + order_by: Sequence[str], + value_list: Sequence[str], +) -> list[tuple[object, ...]]: cache_result = cache.get(cache_key) if cache_result is None: @@ -69,7 +85,13 @@ class Meta: unique_together = (("project", "transaction"),) @classmethod - def filter(cls, project_ids, organization_id, order_by, value_list): + def filter( + cls, + project_ids: Sequence[int], + organization_id: int, + order_by: Sequence[str], + value_list: Sequence[str], + ) -> list[tuple[object, ...]]: cache_key = get_project_threshold_cache_key( "sentry_projecttransactionthresholdoverride", project_ids, @@ -96,7 +118,13 @@ class Meta: db_table = "sentry_projecttransactionthreshold" @classmethod - def filter(cls, project_ids, organization_id, order_by, value_list): + def filter( + cls, + project_ids: Sequence[int], + organization_id: int, + order_by: Sequence[str], + value_list: Sequence[str], + ) -> list[tuple[object, ...]]: cache_key = get_project_threshold_cache_key( "sentry_projecttransactionthreshold", project_ids, diff --git a/src/sentry/snuba/metrics/extraction.py b/src/sentry/snuba/metrics/extraction.py index e044c8e99032..d4f12e81b1c8 100644 --- a/src/sentry/snuba/metrics/extraction.py +++ b/src/sentry/snuba/metrics/extraction.py @@ -1582,7 +1582,7 @@ def _get_satisfactory_metric(project: Project) -> str: if len(result) == 0: metric = TransactionMetric.DURATION.value else: - metric = result[0][0] + metric = cast(int, result[0][0]) if metric == TransactionMetric.DURATION.value: metric_field = "transaction.duration" diff --git a/src/sentry/tasks/groupowner.py b/src/sentry/tasks/groupowner.py index ed56b87029dd..044a40042c17 100644 --- a/src/sentry/tasks/groupowner.py +++ b/src/sentry/tasks/groupowner.py @@ -34,14 +34,14 @@ def _process_suspect_commits( - event_id, - event_platform, + event_id: str, + event_platform: str, event_frames: Sequence[Mapping[str, Any]], - group_id, - project_id, - sdk_name=None, - **kwargs, -): + group_id: int, + project_id: int, + sdk_name: str | None = None, + **kwargs: object, +) -> None: """ This is the logic behind SuspectCommitStrategy.RELEASE_BASED """ @@ -193,14 +193,14 @@ def _process_suspect_commits( silo_mode=SiloMode.CELL, ) def process_suspect_commits( - event_id, - event_platform, + event_id: str, + event_platform: str, event_frames: Sequence[Mapping[str, Any]], - group_id, - project_id, - sdk_name=None, - **kwargs, -): + group_id: int, + project_id: int, + sdk_name: str | None = None, + **kwargs: object, +) -> None: """ This is the task behind SuspectCommitStrategy.RELEASE_BASED """ diff --git a/src/sentry/testutils/requests.py b/src/sentry/testutils/requests.py index 7c9a3b4c4527..54259df41ff5 100644 --- a/src/sentry/testutils/requests.py +++ b/src/sentry/testutils/requests.py @@ -2,7 +2,7 @@ import functools from collections.abc import Callable -from typing import Optional +from typing import Optional, ParamSpec, TypeVar, cast from django.contrib.auth.models import AnonymousUser from django.core.cache import cache @@ -13,22 +13,31 @@ from sentry.auth.principal import set_request_principal from sentry.middleware.auth import AuthenticationMiddleware from sentry.middleware.placeholder import placeholder_get_response +from sentry.models.organization import Organization from sentry.testutils.factories import Factories from sentry.users.models.user import User from sentry.utils.auth import login RequestFactory = Callable[[], Optional[tuple[HttpRequest, User]]] +_P = ParamSpec("_P") +_R = TypeVar( + "_R", + bound=tuple[HttpRequest, User] | tuple[HttpRequest, AnonymousUser] | None, +) -def request_factory(f): + +def request_factory(f: Callable[_P, _R]) -> Callable[_P, _R]: @functools.wraps(f) - def wrapper(*args, **kwds) -> tuple[HttpRequest, User] | None: + def wrapper(*args: _P.args, **kwds: _P.kwargs) -> _R: result = f(*args, **kwds) if result is not None: request, user = result if not user.is_anonymous: login(request, user) - AuthenticationMiddleware(placeholder_get_response).process_request(request) + AuthenticationMiddleware(placeholder_get_response).process_request( + cast(Request, request) + ) else: set_request_principal(request, user, None) cache.clear() @@ -49,7 +58,7 @@ def make_request() -> tuple[HttpRequest, AnonymousUser]: @request_factory -def make_user_request(org=None) -> tuple[HttpRequest, User]: +def make_user_request(org: Organization | None = None) -> tuple[HttpRequest, User]: request, _ = make_request() user = Factories.create_user() org = org or Factories.create_organization() @@ -60,7 +69,7 @@ def make_user_request(org=None) -> tuple[HttpRequest, User]: @request_factory -def make_user_request_from_org(org=None): +def make_user_request_from_org(org: Organization | None = None) -> tuple[HttpRequest, User]: org = org or Factories.create_organization() request, user = make_user_request(org) request.session["activeorg"] = org.slug From 04cbb6f7fd7612fd48b870e9d4cade23da7636da Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:04:52 -0700 Subject: [PATCH 042/103] fix(typing): ignore Any imports in the campaign gate Importing Any does not use it. Skip import statements when tracking new dynamic types so annotation-only import edits do not fail the gate. --- tests/tools/mypy_helpers/test_check_typing_campaign.py | 9 +++++++++ tools/mypy_helpers/check_typing_campaign.py | 9 ++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/tools/mypy_helpers/test_check_typing_campaign.py b/tests/tools/mypy_helpers/test_check_typing_campaign.py index 370fd7d40a3a..9959a0884721 100644 --- a/tests/tools/mypy_helpers/test_check_typing_campaign.py +++ b/tests/tools/mypy_helpers/test_check_typing_campaign.py @@ -82,6 +82,15 @@ def test_any_with_justification_passes(tmp_path) -> None: assert main(("src/sentry/foo.py",)) == 0 +def test_any_import_alone_passes(tmp_path) -> None: + _init_repo(tmp_path) + _commit_pyproject(tmp_path, []) + _stage_file(tmp_path, "src/sentry/foo.py", "from typing import Any\n\nvalue: int = 1\n") + + with contextlib.chdir(tmp_path): + assert main(("src/sentry/foo.py",)) == 0 + + def test_any_in_weaklisted_module_passes(tmp_path) -> None: _init_repo(tmp_path) _commit_pyproject(tmp_path, ["sentry.foo"]) diff --git a/tools/mypy_helpers/check_typing_campaign.py b/tools/mypy_helpers/check_typing_campaign.py index bb4d8e631b95..d1f242a5680e 100644 --- a/tools/mypy_helpers/check_typing_campaign.py +++ b/tools/mypy_helpers/check_typing_campaign.py @@ -116,10 +116,17 @@ def _changed_files(base: str | None) -> list[str]: def _any_lines(text: str) -> list[int]: + import_lines: set[int] = set() + for node in ast.walk(ast.parse(text)): + if isinstance(node, (ast.Import, ast.ImportFrom)): + import_lines.update(range(node.lineno, (node.end_lineno or node.lineno) + 1)) + return [ token.start[0] for token in tokenize.generate_tokens(io.StringIO(text).readline) - if token.type == tokenize.NAME and token.string == "Any" + if token.type == tokenize.NAME + and token.string == "Any" + and token.start[0] not in import_lines ] From eeafaa9aa2c6c6b85cfb556d7a813ad7cccfc469 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:05:10 -0700 Subject: [PATCH 043/103] ref(typing): remove lane 8 weaklist entries Integrate lane 8 and remove its nine entries. Widen the suspect-commit platform, permit anonymous request factories, and narrow the dynamic threshold rows at their query boundary. Also restore deferred annotations for the patched redis helper. --- pyproject.toml | 9 --------- src/sentry/api/serializers/models/group.py | 4 +++- src/sentry/integrations/github/client.py | 7 ++----- .../search/events/datasets/function_aliases.py | 11 ++++++++--- src/sentry/seer/agent/client.py | 3 ++- src/sentry/seer/agent/client_utils.py | 3 ++- src/sentry/tasks/groupowner.py | 4 ++-- src/sentry/testutils/helpers/redis.py | 2 ++ src/sentry/testutils/requests.py | 2 +- tests/sentry/tasks/test_groupowner.py | 14 ++++++++++---- tests/sentry/web/test_client_config.py | 4 ++-- 11 files changed, 34 insertions(+), 29 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d433f27007b6..7155836f3300 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -454,7 +454,6 @@ module = [ "sentry.api.authentication", "sentry.api.base", "sentry.api.client", - "sentry.api.endpoints.auth_config", "sentry.api.endpoints.auth_index", "sentry.api.endpoints.broadcast_index", "sentry.api.endpoints.builtin_symbol_sources", @@ -478,7 +477,6 @@ module = [ "sentry.api.endpoints.relay.*", "sentry.api.fields.*", "sentry.api.paginator", - "sentry.api.serializers.models.artifactbundle", "sentry.api.serializers.models.dashboard", "sentry.api.serializers.models.event", "sentry.api.serializers.models.group_stream", @@ -518,7 +516,6 @@ module = [ "sentry.discover.arithmetic", "sentry.discover.dataset_split", "sentry.discover.endpoints.*", - "sentry.discover.models", "sentry.discover.translation.*", "sentry.explore.endpoints.*", "sentry.explore.translation.*", @@ -558,7 +555,6 @@ module = [ "sentry.integrations.claude_code.*", "sentry.integrations.cursor.*", "sentry.integrations.data_forwarding.*", - "sentry.integrations.discord.client", "sentry.integrations.discord.requests.*", "sentry.integrations.example.*", "sentry.integrations.github.installation", @@ -592,7 +588,6 @@ module = [ "sentry.integrations.slack.actions.*", "sentry.integrations.slack.integration", "sentry.integrations.slack.message_builder.*", - "sentry.integrations.slack.sdk_client", "sentry.integrations.slack.tasks.*", "sentry.integrations.slack.utils.*", "sentry.integrations.slack.webhooks.*", @@ -623,7 +618,6 @@ module = [ "sentry.models.avatars.*", "sentry.models.commit", "sentry.models.commitfilechange", - "sentry.models.counter", "sentry.models.dashboard_widget", "sentry.models.deploy", "sentry.models.environment", @@ -649,7 +643,6 @@ module = [ "sentry.models.releases.*", "sentry.models.repository", "sentry.models.team", - "sentry.models.transaction_threshold", "sentry.monitors.endpoints.*", "sentry.monitors.logic.*", "sentry.monitors.models", @@ -744,7 +737,6 @@ module = [ "sentry.tasks.collect_project_platforms", "sentry.tasks.embeddings_grouping.*", "sentry.tasks.files", - "sentry.tasks.groupowner", "sentry.tasks.relay", "sentry.tasks.seer.agentic_triage.*", "sentry.tasks.seer.backfill_supergroups_lightweight", @@ -773,7 +765,6 @@ module = [ "sentry.testutils.pytest.stale_database_reads", "sentry.testutils.pytest.unittest", "sentry.testutils.relay", - "sentry.testutils.requests", "sentry.testutils.silo", "sentry.toolbar.views.*", "sentry.tsdb.*", diff --git a/src/sentry/api/serializers/models/group.py b/src/sentry/api/serializers/models/group.py index 29a6bf8d26d5..f9cd89837b33 100644 --- a/src/sentry/api/serializers/models/group.py +++ b/src/sentry/api/serializers/models/group.py @@ -517,7 +517,9 @@ def _collapse(self, key: str) -> bool: return key in self.collapse def _get_status( - self, attrs: Mapping[str, Any], obj: Group + self, + attrs: Mapping[str, Any], # justification: serializer attrs are dynamically keyed. + obj: Group, ) -> tuple[GroupStatusDetailsResponseOptional, GroupStatusStr]: status = obj.status status_details: GroupStatusDetailsResponseOptional = {} diff --git a/src/sentry/integrations/github/client.py b/src/sentry/integrations/github/client.py index d8f65cad767b..acc6a3d1c141 100644 --- a/src/sentry/integrations/github/client.py +++ b/src/sentry/integrations/github/client.py @@ -312,11 +312,8 @@ def get_organization_memberships_for_user(self) -> list[GithubUserOrganizationMe class GithubProxyClient(IntegrationProxyClient): integration: Integration | RpcIntegration # late init - def request( - self, - *args: Any, - **kwargs: Any, # justification: proxy forwards arbitrary API requests. - ) -> Any: + # justification: proxy forwards arbitrary API request arguments and returns. + def request(self, *args: Any, **kwargs: Any) -> Any: credentials_set = kwargs.pop("credentials_set", None) if credentials_set == "application": if "headers" not in kwargs: diff --git a/src/sentry/search/events/datasets/function_aliases.py b/src/sentry/search/events/datasets/function_aliases.py index 2d9ad75874fc..41506514e995 100644 --- a/src/sentry/search/events/datasets/function_aliases.py +++ b/src/sentry/search/events/datasets/function_aliases.py @@ -1,5 +1,5 @@ from collections.abc import Callable, Mapping, Sequence -from typing import Any +from typing import Any, cast from snuba_sdk import Column, Entity, Function @@ -59,7 +59,9 @@ def resolve_project_threshold_config( project_threshold_config_keys = [] project_threshold_config_values = [] for project_id, metric in project_threshold_configs: - metric = TRANSACTION_METRICS[metric] + # justification: the threshold query selects these columns in this order. + project_id = cast(int, project_id) + metric = TRANSACTION_METRICS[cast(int, metric)] if metric == constants.DEFAULT_PROJECT_THRESHOLD_METRIC: # small optimization, if the configuration is equal to the default, # we can skip it in the final query @@ -72,7 +74,10 @@ def resolve_project_threshold_config( project_threshold_override_config_keys = [] project_threshold_override_config_values = [] for transaction, project_id, metric in transaction_threshold_configs: - metric = TRANSACTION_METRICS[metric] + # justification: the threshold query selects these columns in this order. + transaction = cast(str, transaction) + project_id = cast(int, project_id) + metric = TRANSACTION_METRICS[cast(int, metric)] if project_id in project_thresholds and metric == project_thresholds[project_id][0]: # small optimization, if the configuration is equal to the project # configs, we can skip it in the final query diff --git a/src/sentry/seer/agent/client.py b/src/sentry/seer/agent/client.py index 563d35c710d9..38232510ca39 100644 --- a/src/sentry/seer/agent/client.py +++ b/src/sentry/seer/agent/client.py @@ -10,6 +10,7 @@ from django.contrib.auth.models import AnonymousUser from django.db.models import Q, QuerySet +from django.http import HttpRequest from django.utils import timezone as django_timezone from django.utils.timezone import now from pydantic import BaseModel @@ -406,7 +407,7 @@ def start_run( artifact_key: str | None = None, artifact_schema: type[BaseModel] | None = None, metadata: dict[str, Any] | None = None, - request: Request | None = None, + request: HttpRequest | Request | None = None, override_ce_enable: bool = True, force_ce: bool | None = None, ui_tools: str | None = None, diff --git a/src/sentry/seer/agent/client_utils.py b/src/sentry/seer/agent/client_utils.py index f453c1065901..ec762957e86a 100644 --- a/src/sentry/seer/agent/client_utils.py +++ b/src/sentry/seer/agent/client_utils.py @@ -17,6 +17,7 @@ from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.db import router, transaction +from django.http import HttpRequest from django.utils.timezone import now from rest_framework.request import Request from urllib3 import BaseHTTPResponse, HTTPConnectionPool @@ -423,7 +424,7 @@ def has_seer_agent_access_with_detail( def collect_user_org_context( user: SentryUser | RpcUser | AnonymousUser | None, organization: Organization, - request: Request | None = None, + request: HttpRequest | Request | None = None, ) -> UserOrgContext: """Collect user and organization context for a new agent run.""" all_projects = Project.objects.filter( diff --git a/src/sentry/tasks/groupowner.py b/src/sentry/tasks/groupowner.py index 044a40042c17..c8f827dad4e3 100644 --- a/src/sentry/tasks/groupowner.py +++ b/src/sentry/tasks/groupowner.py @@ -35,7 +35,7 @@ def _process_suspect_commits( event_id: str, - event_platform: str, + event_platform: str | None, event_frames: Sequence[Mapping[str, Any]], group_id: int, project_id: int, @@ -194,7 +194,7 @@ def _process_suspect_commits( ) def process_suspect_commits( event_id: str, - event_platform: str, + event_platform: str | None, event_frames: Sequence[Mapping[str, Any]], group_id: int, project_id: int, diff --git a/src/sentry/testutils/helpers/redis.py b/src/sentry/testutils/helpers/redis.py index a55c65f9e1b7..6e74587b5eca 100644 --- a/src/sentry/testutils/helpers/redis.py +++ b/src/sentry/testutils/helpers/redis.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from collections.abc import Generator from contextlib import contextmanager from typing import Any diff --git a/src/sentry/testutils/requests.py b/src/sentry/testutils/requests.py index 54259df41ff5..bae7ccc07b59 100644 --- a/src/sentry/testutils/requests.py +++ b/src/sentry/testutils/requests.py @@ -18,7 +18,7 @@ from sentry.users.models.user import User from sentry.utils.auth import login -RequestFactory = Callable[[], Optional[tuple[HttpRequest, User]]] +RequestFactory = Callable[[], Optional[tuple[HttpRequest, User | AnonymousUser]]] _P = ParamSpec("_P") _R = TypeVar( diff --git a/tests/sentry/tasks/test_groupowner.py b/tests/sentry/tasks/test_groupowner.py index c681381c5b10..1e42b774cc75 100644 --- a/tests/sentry/tasks/test_groupowner.py +++ b/tests/sentry/tasks/test_groupowner.py @@ -232,11 +232,13 @@ def test_delete_old_entries(self) -> None: self.set_release_commits(self.user_2.email) event_2_frames = get_frame_paths(event_2) + event_2_group_id = event_2.group_id + assert event_2_group_id is not None process_suspect_commits( event_id=event_2.event_id, event_platform=event_2.platform, event_frames=event_2_frames, - group_id=event_2.group_id, + group_id=event_2_group_id, project_id=event_2.project_id, ) assert GroupOwner.objects.filter(group=self.event.group).count() == 2 @@ -245,11 +247,13 @@ def test_delete_old_entries(self) -> None: self.set_release_commits(self.user_3.email) event_3_frames = get_frame_paths(event_3) + event_3_group_id = event_3.group_id + assert event_3_group_id is not None process_suspect_commits( event_id=event_3.event_id, event_platform=event_3.platform, event_frames=event_3_frames, - group_id=event_3.group_id, + group_id=event_3_group_id, project_id=event_3.project_id, ) assert GroupOwner.objects.filter(group=self.event.group).count() == 2 @@ -266,7 +270,7 @@ def test_delete_old_entries(self) -> None: event_id=event_3.event_id, event_platform=event_3.platform, event_frames=event_3_frames, - group_id=event_3.group_id, + group_id=event_3_group_id, project_id=event_3.project_id, ) # Won't be processed because the cache is present and this group has owners @@ -341,11 +345,13 @@ def test_no_release_or_commit(self) -> None: }, project_id=self.project.id, ) + event_group_id = event_with_no_release.group_id + assert event_group_id is not None process_suspect_commits( event_with_no_release.event_id, event_with_no_release.platform, get_frame_paths(event_with_no_release), - event_with_no_release.group_id, + event_group_id, event_with_no_release.project_id, ) assert GroupOwner.objects.filter(group=event_with_no_release.group).count() == 0 diff --git a/tests/sentry/web/test_client_config.py b/tests/sentry/web/test_client_config.py index 3d1d3e15a4e2..51f58d1ac801 100644 --- a/tests/sentry/web/test_client_config.py +++ b/tests/sentry/web/test_client_config.py @@ -287,7 +287,7 @@ def test_client_config_with_staff_session_fills_cells_and_cells_are_sorted() -> # Simulate an active staff session staff = Staff(request) staff.set_logged_in(user) - request.staff = staff + setattr(request, "staff", staff) result = get_client_config(request) @@ -317,7 +317,7 @@ def test_client_config_with_staff_session_includes_hidden_cells() -> None: # Simulate an active staff session staff = Staff(request) staff.set_logged_in(user) - request.staff = staff + setattr(request, "staff", staff) result = get_client_config(request) From b2f21446162109f5505ca7835dcdec8e6f7788ea Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:10:35 -0700 Subject: [PATCH 044/103] ref(typing): remove 16 clean weaklist wildcards These test and support packages already pass the stricter config across all 73 files they cover, so their wildcard exemptions no longer add enforcement. --- pyproject.toml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7155836f3300..522a80ae58e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -496,7 +496,6 @@ module = [ "sentry.backup.*", "sentry.billing.*", "sentry.cache.*", - "sentry.conduit.*", "sentry.conf.*", "sentry.consumers.*", "sentry.core.*", @@ -519,7 +518,6 @@ module = [ "sentry.discover.translation.*", "sentry.explore.endpoints.*", "sentry.explore.translation.*", - "sentry.features.*", "sentry.feedback.endpoints.*", "sentry.feedback.lib.*", "sentry.feedback.usecases.*", @@ -560,7 +558,6 @@ module = [ "sentry.integrations.github.installation", "sentry.integrations.github.integration", "sentry.integrations.github.tasks.*", - "sentry.integrations.github_copilot.*", "sentry.integrations.github_enterprise.client", "sentry.integrations.github_enterprise.integration", "sentry.integrations.github_enterprise.webhook", @@ -589,7 +586,6 @@ module = [ "sentry.integrations.slack.integration", "sentry.integrations.slack.message_builder.*", "sentry.integrations.slack.tasks.*", - "sentry.integrations.slack.utils.*", "sentry.integrations.slack.webhooks.*", "sentry.integrations.tasks.*", "sentry.integrations.time_utils", @@ -667,9 +663,7 @@ module = [ "sentry.plugins.interfaces.*", "sentry.plugins.providers.base", "sentry.processing.*", - "sentry.processing_errors.*", "sentry.projectoptions.*", - "sentry.quotas.*", "sentry.receivers.*", "sentry.relay.datascrubbing", "sentry.relay.projectconfig_cache.*", @@ -701,7 +695,6 @@ module = [ "sentry.seer.endpoints.*", "sentry.seer.entrypoints.*", "sentry.seer.models.*", - "sentry.seer.supergroups.*", "sentry.sentry_apps.api.bases.*", "sentry.sentry_apps.api.endpoints.*", "sentry.sentry_apps.api.parsers.*", @@ -738,7 +731,6 @@ module = [ "sentry.tasks.embeddings_grouping.*", "sentry.tasks.files", "sentry.tasks.relay", - "sentry.tasks.seer.agentic_triage.*", "sentry.tasks.seer.backfill_supergroups_lightweight", "sentry.tasks.seer.context_engine_index", "sentry.tasks.statistical_detectors", @@ -1005,7 +997,6 @@ module = [ "tests.sentry.identity.*", "tests.sentry.incidents", "tests.sentry.incidents.endpoints.*", - "tests.sentry.incidents.models.*", "tests.sentry.incidents.subscription_processor.*", "tests.sentry.incidents.test_charts", "tests.sentry.incidents.test_logic", @@ -1016,7 +1007,6 @@ module = [ "tests.sentry.integrations", "tests.sentry.integrations.api.bases.*", "tests.sentry.integrations.api.endpoints.*", - "tests.sentry.integrations.aws_lambda.*", "tests.sentry.integrations.bitbucket.*", "tests.sentry.integrations.bitbucket_server.*", "tests.sentry.integrations.claude_code.*", @@ -1048,7 +1038,6 @@ module = [ "tests.sentry.integrations.slack", "tests.sentry.integrations.slack.notifications.*", "tests.sentry.integrations.slack.service.*", - "tests.sentry.integrations.slack.staging.*", "tests.sentry.integrations.slack.tasks.*", "tests.sentry.integrations.slack.test_integration", "tests.sentry.integrations.slack.test_link_identity", @@ -1074,7 +1063,6 @@ module = [ "tests.sentry.integrations.test_pipeline", "tests.sentry.integrations.utils.*", "tests.sentry.integrations.vsts.*", - "tests.sentry.issues.related.*", "tests.sentry.issues.test_ingest_incident_integration", "tests.sentry.issues.test_issue_search", "tests.sentry.issues.test_utils", @@ -1175,7 +1163,6 @@ module = [ "tests.sentry.notifications.test_notificationcontroller", "tests.sentry.notifications.test_notifications", "tests.sentry.notifications.test_utils", - "tests.sentry.objectstore.*", "tests.sentry.options.*", "tests.sentry.pipeline.*", "tests.sentry.preprod.*", @@ -1299,10 +1286,8 @@ module = [ "tests.sentry.stacktraces.*", "tests.sentry.statistical_detectors.*", "tests.sentry.sudo.*", - "tests.sentry.synapse.*", "tests.sentry.tagstore.*", "tests.sentry.tasks", - "tests.sentry.tasks.scim.*", "tests.sentry.tasks.seer", "tests.sentry.tasks.seer.agentic_triage.*", "tests.sentry.tasks.seer.test_agentic_triage", @@ -1489,7 +1474,6 @@ module = [ "tests.snuba.billing.*", "tests.snuba.incidents.*", "tests.snuba.preprod.*", - "tests.snuba.processing_errors.*", "tests.snuba.search.*", "tests.snuba.sessions.*", "tests.snuba.tagstore.*", From 0e18cc683646382d280f4648ebf06f80d924f4af Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:14:23 -0700 Subject: [PATCH 045/103] ref(typing): remove eight clean weaklist wildcards Type the remaining helpers in these packages and exercise their files under the strict config, then drop the wildcard exemptions. --- pyproject.toml | 8 -------- src/sentry/insights/endpoints/starred_segments.py | 2 +- src/sentry/partnerships/base.py | 2 +- src/sentry/rules/processing/processor.py | 12 ++++++++---- src/sentry/tasks/embeddings_grouping/utils.py | 11 ++++++++++- tests/sentry/data/test_samples.py | 2 +- .../commands/test_generate_controlsilo_urls.py | 2 +- tests/sentry/utils/kafka/test_rebalance_delay.py | 3 ++- tests/snuba/incidents/test_tasks.py | 11 ++++++++--- 9 files changed, 32 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 522a80ae58e7..8e0573256427 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -538,7 +538,6 @@ module = [ "sentry.ingest.consumer.*", "sentry.ingest.inbound_filters", "sentry.ingest.transaction_clusterer.*", - "sentry.insights.endpoints.*", "sentry.integrations.api.bases.*", "sentry.integrations.api.endpoints.data_forwarding_details", "sentry.integrations.api.endpoints.data_forwarding_index", @@ -658,7 +657,6 @@ module = [ "sentry.onboarding_tasks.*", "sentry.options.manager", "sentry.options.store", - "sentry.partnerships.*", "sentry.plugins.base.binding_manager", "sentry.plugins.interfaces.*", "sentry.plugins.providers.base", @@ -682,7 +680,6 @@ module = [ "sentry.replays.testutils", "sentry.replays.usecases.*", "sentry.rules.filters.*", - "sentry.rules.processing.*", "sentry.scm.*", "sentry.search.events.builder.*", "sentry.search.events.datasets.*", @@ -728,7 +725,6 @@ module = [ "sentry.tasks.auto_ongoing_issues", "sentry.tasks.auto_resolve_issues", "sentry.tasks.collect_project_platforms", - "sentry.tasks.embeddings_grouping.*", "sentry.tasks.files", "sentry.tasks.relay", "sentry.tasks.seer.backfill_supergroups_lightweight", @@ -965,7 +961,6 @@ module = [ "tests.sentry.consumers.*", "tests.sentry.core.*", "tests.sentry.dashboards.*", - "tests.sentry.data.*", "tests.sentry.db", "tests.sentry.db.models.*", "tests.sentry.db.postgres.*", @@ -1074,7 +1069,6 @@ module = [ "tests.sentry.mail", "tests.sentry.mail.test_actions", "tests.sentry.mail.test_adapter", - "tests.sentry.management.*", "tests.sentry.metrics.*", "tests.sentry.middleware.*", "tests.sentry.migrations.*", @@ -1356,7 +1350,6 @@ module = [ "tests.sentry.uptime.test_models", "tests.sentry.uptime.test_utils", "tests.sentry.utils", - "tests.sentry.utils.kafka.*", "tests.sentry.utils.kvstore", "tests.sentry.utils.kvstore.test_common", "tests.sentry.utils.kvstore.test_compat", @@ -1472,7 +1465,6 @@ module = [ "tests.snuba.api", "tests.snuba.api.endpoints.*", "tests.snuba.billing.*", - "tests.snuba.incidents.*", "tests.snuba.preprod.*", "tests.snuba.search.*", "tests.snuba.sessions.*", diff --git a/src/sentry/insights/endpoints/starred_segments.py b/src/sentry/insights/endpoints/starred_segments.py index c0b54ae50df1..c554da43dd9d 100644 --- a/src/sentry/insights/endpoints/starred_segments.py +++ b/src/sentry/insights/endpoints/starred_segments.py @@ -59,7 +59,7 @@ class OrganizationStarredServiceSpansEndpoint(OrganizationEndpoint): owner = ApiOwner.DATA_BROWSING permission_classes = (MemberPermission,) - def has_feature(self, organization, request): + def has_feature(self, organization: Organization, request: Request) -> bool: return features.has( "organizations:insights-modules-use-eap", organization, actor=request.user ) diff --git a/src/sentry/partnerships/base.py b/src/sentry/partnerships/base.py index 19ded0123077..0b06025998ed 100644 --- a/src/sentry/partnerships/base.py +++ b/src/sentry/partnerships/base.py @@ -12,7 +12,7 @@ class Partnership(Service): __all__ = ("get_inbound_filters",) - def __init__(self, **options): + def __init__(self, **options: object) -> None: pass def get_inbound_filters(self, organization: Organization) -> list[GenericFilter]: diff --git a/src/sentry/rules/processing/processor.py b/src/sentry/rules/processing/processor.py index 2356156174c9..59f427da0128 100644 --- a/src/sentry/rules/processing/processor.py +++ b/src/sentry/rules/processing/processor.py @@ -23,11 +23,15 @@ def get_rule_type(condition: Mapping[str, Any]) -> str | None: return rule_type +# justification: rule conditions are decoded from rule configuration JSON. +RuleCondition = Mapping[str, Any] + + def split_conditions_and_filters( - rule_condition_list, -) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - condition_list = [] - filter_list = [] + rule_condition_list: Sequence[RuleCondition], +) -> tuple[list[RuleCondition], list[RuleCondition]]: + condition_list: list[RuleCondition] = [] + filter_list: list[RuleCondition] = [] for rule_cond in rule_condition_list: if get_rule_type(rule_cond) == "condition/event": condition_list.append(rule_cond) diff --git a/src/sentry/tasks/embeddings_grouping/utils.py b/src/sentry/tasks/embeddings_grouping/utils.py index 7b100cd44a51..04f2a006d091 100644 --- a/src/sentry/tasks/embeddings_grouping/utils.py +++ b/src/sentry/tasks/embeddings_grouping/utils.py @@ -1,8 +1,16 @@ import time +from collections.abc import Callable # Marked as internal with underscore but actually used in getsentry -def _retry_operation(operation, *args, retries, delay, exceptions, **kwargs): +def _retry_operation( + operation: Callable[..., object], + *args: object, + retries: int, + delay: float, + exceptions: type[BaseException] | tuple[type[BaseException], ...], + **kwargs: object, +) -> object | None: for attempt in range(retries): try: return operation(*args, **kwargs) @@ -11,3 +19,4 @@ def _retry_operation(operation, *args, retries, delay, exceptions, **kwargs): time.sleep(delay * (2**attempt)) else: raise + return None diff --git a/tests/sentry/data/test_samples.py b/tests/sentry/data/test_samples.py index 991ada27957c..57229e369eef 100644 --- a/tests/sentry/data/test_samples.py +++ b/tests/sentry/data/test_samples.py @@ -7,6 +7,6 @@ @pytest.mark.parametrize("filename", os.listdir(os.path.join(DATA_ROOT, "samples"))) -def test_is_valid_json(filename) -> None: +def test_is_valid_json(filename: str) -> None: with open(os.path.join(DATA_ROOT, "samples", filename)) as f: json.loads(f.read()) diff --git a/tests/sentry/management/commands/test_generate_controlsilo_urls.py b/tests/sentry/management/commands/test_generate_controlsilo_urls.py index 5c75cc3d235a..b3295b4ecc7a 100644 --- a/tests/sentry/management/commands/test_generate_controlsilo_urls.py +++ b/tests/sentry/management/commands/test_generate_controlsilo_urls.py @@ -9,7 +9,7 @@ class TestGenerateControlsiloUrls(TestCase): - def call_command(self, *args, **kwargs): + def call_command(self, *args: object, **kwargs: object) -> str: out = StringIO() call_command("generate_controlsilo_urls", *args, stdout=out, stderr=StringIO(), **kwargs) return out.getvalue() diff --git a/tests/sentry/utils/kafka/test_rebalance_delay.py b/tests/sentry/utils/kafka/test_rebalance_delay.py index 85fa7b3db0f6..9de497e507bc 100644 --- a/tests/sentry/utils/kafka/test_rebalance_delay.py +++ b/tests/sentry/utils/kafka/test_rebalance_delay.py @@ -1,4 +1,5 @@ import time +from collections.abc import Iterator from unittest import mock import pytest @@ -8,7 +9,7 @@ @pytest.fixture -def frozen_time_with_warp(): +def frozen_time_with_warp() -> Iterator[None]: with freeze_time() as frozen: with mock.patch.object(time, "sleep", frozen.shift): yield diff --git a/tests/snuba/incidents/test_tasks.py b/tests/snuba/incidents/test_tasks.py index e105d7438af2..e6bc6eab2304 100644 --- a/tests/snuba/incidents/test_tasks.py +++ b/tests/snuba/incidents/test_tasks.py @@ -4,7 +4,10 @@ from arroyo.utils import metrics from sentry.incidents.utils.constants import INCIDENTS_SNUBA_SUBSCRIPTION_TYPE -from sentry.incidents.utils.types import DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION +from sentry.incidents.utils.types import ( + DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION, + QuerySubscriptionUpdate, +) from sentry.snuba.dataset import Dataset from sentry.snuba.models import QuerySubscription, SnubaQuery from sentry.snuba.query_subscriptions.consumer import subscriber_registry @@ -130,11 +133,13 @@ def run_test(self) -> None: original_callback = subscriber_registry[INCIDENTS_SNUBA_SUBSCRIPTION_TYPE] callback_invoked = [] - def shutdown_callback(*args, **kwargs): + def shutdown_callback( + update: QuerySubscriptionUpdate, subscription: QuerySubscription + ) -> None: # We want to just exit after the callback so that we can see the result of # processing. callback_invoked.append(True) - original_callback(*args, **kwargs) + original_callback(update, subscription) subscriber_registry[INCIDENTS_SNUBA_SUBSCRIPTION_TYPE] = shutdown_callback From 08fd816ad6a686b7de6b470604f9eb5ab624494a Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:21:45 -0700 Subject: [PATCH 046/103] ref(typing): type fifteen weaklist wildcard families Annotate the remaining helpers in these packages and narrow the msteams card assertions with the existing block type guards, then drop the wildcard exemptions. Full mypy passes. --- pyproject.toml | 15 --------------- src/sentry/api/validators/auth.py | 5 +++-- src/sentry/identity/providers/dummy.py | 3 ++- .../integrations/github/tasks/link_all_repos.py | 2 +- .../utils/create_issue_schema_transformers.py | 6 ++++-- .../msteams/card_builder/notifications.py | 3 ++- .../integrations/slack/webhooks/options_load.py | 2 +- src/sentry/processing/backpressure/memory.py | 2 +- src/sentry/replays/lib/eap/snuba_transpiler.py | 2 +- src/sentry/rules/filters/issue_type.py | 2 +- src/sentry/seer/assisted_query/discover_tools.py | 2 +- src/sentry/synapse/paginator.py | 4 ++-- tests/sentry/conf/types/test_imports.py | 2 +- .../integrations/msteams/test_message_builder.py | 8 +++++++- tests/sentry/lang/java/test_exceptions.py | 3 ++- .../monitors/clock_tasks/test_check_missed.py | 10 +++++----- tests/sentry/newsletter/test_dummy.py | 3 ++- 17 files changed, 36 insertions(+), 38 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8e0573256427..06ad19112cd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -488,7 +488,6 @@ module = [ "sentry.api.serializers.rest_framework.base", "sentry.api.serializers.rest_framework.dashboard", "sentry.api.serializers.snuba", - "sentry.api.validators.*", "sentry.apidocs.hooks", "sentry.apidocs.spectacular_ports", "sentry.assistant.*", @@ -531,7 +530,6 @@ module = [ "sentry.identity.google.*", "sentry.identity.manager", "sentry.identity.oauth2", - "sentry.identity.providers.*", "sentry.identity.slack.*", "sentry.identity.vsts.*", "sentry.incidents.endpoints.serializers.*", @@ -556,14 +554,12 @@ module = [ "sentry.integrations.example.*", "sentry.integrations.github.installation", "sentry.integrations.github.integration", - "sentry.integrations.github.tasks.*", "sentry.integrations.github_enterprise.client", "sentry.integrations.github_enterprise.integration", "sentry.integrations.github_enterprise.webhook", "sentry.integrations.gitlab.*", "sentry.integrations.jira.client", "sentry.integrations.jira.integration", - "sentry.integrations.jira.utils.*", "sentry.integrations.jira.views.*", "sentry.integrations.jira.webhooks.*", "sentry.integrations.jira_server.client", @@ -574,7 +570,6 @@ module = [ "sentry.integrations.mixins.*", "sentry.integrations.models.integration", "sentry.integrations.msteams.actions.*", - "sentry.integrations.msteams.card_builder.*", "sentry.integrations.msteams.client", "sentry.integrations.msteams.utils", "sentry.integrations.msteams.webhook", @@ -585,7 +580,6 @@ module = [ "sentry.integrations.slack.integration", "sentry.integrations.slack.message_builder.*", "sentry.integrations.slack.tasks.*", - "sentry.integrations.slack.webhooks.*", "sentry.integrations.tasks.*", "sentry.integrations.time_utils", "sentry.integrations.vsts.client", @@ -660,7 +654,6 @@ module = [ "sentry.plugins.base.binding_manager", "sentry.plugins.interfaces.*", "sentry.plugins.providers.base", - "sentry.processing.*", "sentry.projectoptions.*", "sentry.receivers.*", "sentry.relay.datascrubbing", @@ -673,13 +666,11 @@ module = [ "sentry.replays._case_studies.*", "sentry.replays.endpoints.*", "sentry.replays.lib.cache", - "sentry.replays.lib.eap.*", "sentry.replays.lib.kafka", "sentry.replays.lib.query", "sentry.replays.query", "sentry.replays.testutils", "sentry.replays.usecases.*", - "sentry.rules.filters.*", "sentry.scm.*", "sentry.search.events.builder.*", "sentry.search.events.datasets.*", @@ -687,7 +678,6 @@ module = [ "sentry.search.snuba.executors", "sentry.seer.agent.*", "sentry.seer.anomaly_detection.*", - "sentry.seer.assisted_query.*", "sentry.seer.autofix.*", "sentry.seer.endpoints.*", "sentry.seer.entrypoints.*", @@ -718,7 +708,6 @@ module = [ "sentry.stacktraces.functions", "sentry.stacktraces.processing", "sentry.statistical_detectors.*", - "sentry.synapse.*", "sentry.tagstore.*", "sentry.tasks.assemble", "sentry.tasks.auth.*", @@ -957,7 +946,6 @@ module = [ "tests.sentry.buffer.*", "tests.sentry.cache.*", "tests.sentry.conduit.*", - "tests.sentry.conf.*", "tests.sentry.consumers.*", "tests.sentry.core.*", "tests.sentry.dashboards.*", @@ -1062,7 +1050,6 @@ module = [ "tests.sentry.issues.test_issue_search", "tests.sentry.issues.test_utils", "tests.sentry.lang", - "tests.sentry.lang.java.*", "tests.sentry.lang.javascript.*", "tests.sentry.lang.native.*", "tests.sentry.logging.*", @@ -1132,7 +1119,6 @@ module = [ "tests.sentry.models.test_team", "tests.sentry.models.test_teamkeytransaction", "tests.sentry.monitors", - "tests.sentry.monitors.clock_tasks.*", "tests.sentry.monitors.consumers.*", "tests.sentry.monitors.endpoints.*", "tests.sentry.monitors.logic.*", @@ -1147,7 +1133,6 @@ module = [ "tests.sentry.monitors.test_types", "tests.sentry.monitors.test_utils", "tests.sentry.monitors.test_validators", - "tests.sentry.newsletter.*", "tests.sentry.notifications.api.*", "tests.sentry.notifications.notification_action.test_group_type_notification_registry_handlers", "tests.sentry.notifications.notification_action.test_issue_alert_registry_handlers", diff --git a/src/sentry/api/validators/auth.py b/src/sentry/api/validators/auth.py index 31ca3c567db6..9aca43358206 100644 --- a/src/sentry/api/validators/auth.py +++ b/src/sentry/api/validators/auth.py @@ -1,4 +1,4 @@ -from typing import NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict from django.core.exceptions import ValidationError as DjangoValidationError from django.db.models import Q @@ -56,7 +56,8 @@ class RegistrationValidator(serializers.Serializer[RegistrationRequest]): choices=get_timezone_choices(), required=False, allow_blank=True ) - def __init__(self, *args, **kwargs): + # justification: DRF passes dynamic serializer constructor keywords. + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) if not newsletter.backend.is_enabled(): self.fields.pop("subscribe") diff --git a/src/sentry/identity/providers/dummy.py b/src/sentry/identity/providers/dummy.py index a0c46ac25279..9f3e95efb777 100644 --- a/src/sentry/identity/providers/dummy.py +++ b/src/sentry/identity/providers/dummy.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from django.http import HttpResponse @@ -31,7 +32,7 @@ class DummyProvider(Provider): def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]: return [AskEmail()] - def build_identity(self, state): + def build_identity(self, state: Mapping[str, object]) -> Mapping[str, object]: return {"id": state["email"], "email": state["email"], "name": "Dummy"} def refresh_identity(self, identity: Identity | RpcIdentity, **kwargs: Any) -> None: diff --git a/src/sentry/integrations/github/tasks/link_all_repos.py b/src/sentry/integrations/github/tasks/link_all_repos.py index f0d7eb038ad5..d5ebe4cf5909 100644 --- a/src/sentry/integrations/github/tasks/link_all_repos.py +++ b/src/sentry/integrations/github/tasks/link_all_repos.py @@ -48,7 +48,7 @@ def link_all_repos( integration_key: str, integration_id: int, organization_id: int, -): +) -> None: with SCMIntegrationInteractionEvent( interaction_type=SCMIntegrationInteractionType.LINK_ALL_REPOS, integration_id=integration_id, diff --git a/src/sentry/integrations/jira/utils/create_issue_schema_transformers.py b/src/sentry/integrations/jira/utils/create_issue_schema_transformers.py index 64f90bfbf834..487378ac1a3a 100644 --- a/src/sentry/integrations/jira/utils/create_issue_schema_transformers.py +++ b/src/sentry/integrations/jira/utils/create_issue_schema_transformers.py @@ -83,8 +83,10 @@ def get_transformer_for_field( def transform_fields( - user_id_field: str, jira_fields: Iterable[JiraField], **data -) -> Mapping[str, Any]: + user_id_field: str, + jira_fields: Iterable[JiraField], + **data: Any, # justification: alert configuration values are heterogeneous JSON. +) -> Mapping[str, object]: transformed_data = {} # Special handling for fields that don't map cleanly to the transformer logic diff --git a/src/sentry/integrations/msteams/card_builder/notifications.py b/src/sentry/integrations/msteams/card_builder/notifications.py index ec4faf22e8f5..e1d18750b872 100644 --- a/src/sentry/integrations/msteams/card_builder/notifications.py +++ b/src/sentry/integrations/msteams/card_builder/notifications.py @@ -21,6 +21,7 @@ from .block import ( Action, ActionType, + AdaptiveCard, ColumnSetBlock, TextBlock, TextSize, @@ -106,7 +107,7 @@ def create_action_blocks(actions: Sequence[MessageAction]) -> list[Action]: return action_blocks - def build_notification_card(self): + def build_notification_card(self) -> AdaptiveCard: fields = [self.create_attachment_title_block(), self.create_description_block()] # TODO: Add support for notification actions. diff --git a/src/sentry/integrations/slack/webhooks/options_load.py b/src/sentry/integrations/slack/webhooks/options_load.py index db5a60a499c3..fe4ab86f4a20 100644 --- a/src/sentry/integrations/slack/webhooks/options_load.py +++ b/src/sentry/integrations/slack/webhooks/options_load.py @@ -37,7 +37,7 @@ class SlackOptionsLoadEndpoint(Endpoint): permission_classes = () slack_request_class = SlackOptionsLoadRequest - def is_substring(self, string, substring): + def is_substring(self, string: str, substring: str) -> bool: # in case either have special characters, we want to preserve the strings # as is, so we escape both before applying re.match substring = re.escape(substring) diff --git a/src/sentry/processing/backpressure/memory.py b/src/sentry/processing/backpressure/memory.py index 516595171643..803139b1d746 100644 --- a/src/sentry/processing/backpressure/memory.py +++ b/src/sentry/processing/backpressure/memory.py @@ -32,7 +32,7 @@ class NodeInfo: # Based on configuration, this could be: # - a `rediscluster` Cluster (actually `RetryingRedisCluster`) # - a `rb.Cluster` (client side routing cluster client) -Cluster = Union[RedisCluster, rb.Cluster, StrictRedis] +Cluster = Union[RedisCluster, rb.Cluster, "StrictRedis[bytes]"] def get_memory_usage(node_id: str, info: Mapping[str, Any]) -> ServiceMemory: diff --git a/src/sentry/replays/lib/eap/snuba_transpiler.py b/src/sentry/replays/lib/eap/snuba_transpiler.py index 600f1caf0a8e..786bbe34a159 100644 --- a/src/sentry/replays/lib/eap/snuba_transpiler.py +++ b/src/sentry/replays/lib/eap/snuba_transpiler.py @@ -383,7 +383,7 @@ class Settings(TypedDict, total=False): """ -def execute_query(request: TraceItemTableRequest, referrer: str): +def execute_query(request: TraceItemTableRequest, referrer: str) -> TraceItemTableResponse: request_method = "POST" request_body = request.SerializeToString() request_url = "/rpc/EndpointTraceItemTable/v1" diff --git a/src/sentry/rules/filters/issue_type.py b/src/sentry/rules/filters/issue_type.py index e0d9c7df68f4..7c31372bec96 100644 --- a/src/sentry/rules/filters/issue_type.py +++ b/src/sentry/rules/filters/issue_type.py @@ -24,7 +24,7 @@ class IssueTypeFilter(EventFilter): prompt = "The issue's type is ..." @property - def form_fields(self) -> dict: + def form_fields(self) -> dict[str, object]: return { "include": { "type": "choice", diff --git a/src/sentry/seer/assisted_query/discover_tools.py b/src/sentry/seer/assisted_query/discover_tools.py index e50334b43341..8f9dd3072e49 100644 --- a/src/sentry/seer/assisted_query/discover_tools.py +++ b/src/sentry/seer/assisted_query/discover_tools.py @@ -250,7 +250,7 @@ def get_event_filter_keys( def get_event_filter_key_values( *, - org_id, + org_id: int, filter_key: str, substring: str | None = None, project_ids: list[int] | None = None, diff --git a/src/sentry/synapse/paginator.py b/src/sentry/synapse/paginator.py index 069ad5a7a777..61f4d6974103 100644 --- a/src/sentry/synapse/paginator.py +++ b/src/sentry/synapse/paginator.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from typing import Any -from django.db.models import Q, QuerySet +from django.db.models import Model, Q, QuerySet from django.db.models.functions import TruncSecond from rest_framework.exceptions import ParseError @@ -58,7 +58,7 @@ class SynapsePaginator: def __init__( self, - queryset: QuerySet, + queryset: QuerySet[Model], id_field: str, timestamp_field: str, ) -> None: diff --git a/tests/sentry/conf/types/test_imports.py b/tests/sentry/conf/types/test_imports.py index f6b4026b9257..661f66e9e00d 100644 --- a/tests/sentry/conf/types/test_imports.py +++ b/tests/sentry/conf/types/test_imports.py @@ -25,7 +25,7 @@ @pytest.mark.parametrize("modname", MODNAMES) -def test_module_does_not_import_sentry(modname) -> None: +def test_module_does_not_import_sentry(modname: str) -> None: prog = f"""\ import json import sys diff --git a/tests/sentry/integrations/msteams/test_message_builder.py b/tests/sentry/integrations/msteams/test_message_builder.py index bdf8a1c8b357..b6e9eb0c27f7 100644 --- a/tests/sentry/integrations/msteams/test_message_builder.py +++ b/tests/sentry/integrations/msteams/test_message_builder.py @@ -569,25 +569,31 @@ def test_simple(self) -> None: assert 4 == len(body) title = body[0] + assert _is_text_block(title) assert "Notification Title with some\\_value" == title["text"] group_title = body[1] + assert _is_text_block(group_title) assert "[My Title]" in group_title["text"] assert TextSize.LARGE == group_title["size"] assert TextWeight.BOLDER == group_title["weight"] description = body[2] + assert _is_text_block(description) assert "Message Description" in description["text"] assert TextSize.MEDIUM == description["size"] footer = body[3] - assert 2 == len(footer) + assert _is_column_set_block(footer) + assert 2 == len(footer["columns"]) logo = footer["columns"][0]["items"][0] + assert _is_image_block(logo) assert "Image" == logo["type"] assert "20px" == logo["height"] footer_text = footer["columns"][1]["items"][0] + assert _is_text_block(footer_text) assert "Notification Footer" in footer_text["text"] assert TextSize.SMALL == footer_text["size"] diff --git a/tests/sentry/lang/java/test_exceptions.py b/tests/sentry/lang/java/test_exceptions.py index b41ff972c8a7..4d376c308e7c 100644 --- a/tests/sentry/lang/java/test_exceptions.py +++ b/tests/sentry/lang/java/test_exceptions.py @@ -1,10 +1,11 @@ import re +from collections.abc import Mapping, Sequence from typing import Any from sentry.lang.java.exceptions import Exceptions -def build_event(exceptions_values): +def build_event(exceptions_values: Sequence[Mapping[str, object]]) -> dict[str, object]: return {"exception": {"values": exceptions_values}} diff --git a/tests/sentry/monitors/clock_tasks/test_check_missed.py b/tests/sentry/monitors/clock_tasks/test_check_missed.py index cdd7f4018022..609e20407847 100644 --- a/tests/sentry/monitors/clock_tasks/test_check_missed.py +++ b/tests/sentry/monitors/clock_tasks/test_check_missed.py @@ -448,11 +448,11 @@ def test_missing_checkin_with_skipped_clock_ticks( @mock.patch("sentry.monitors.clock_tasks.check_missed.produce_task") def assert_state_does_not_change_for_status( self, - state, - mock_produce_task, - is_muted=False, - environment_is_muted=False, - ): + state: int, + mock_produce_task: mock.MagicMock, + is_muted: bool = False, + environment_is_muted: bool = False, + ) -> None: org = self.create_organization() project = self.create_project(organization=org) diff --git a/tests/sentry/newsletter/test_dummy.py b/tests/sentry/newsletter/test_dummy.py index 52d5b8239fcc..4ffe8efcc2a6 100644 --- a/tests/sentry/newsletter/test_dummy.py +++ b/tests/sentry/newsletter/test_dummy.py @@ -1,6 +1,7 @@ from sentry.newsletter.dummy import DummyNewsletter from sentry.testutils.cases import TestCase from sentry.testutils.silo import control_silo_test +from sentry.users.models.user import User @control_silo_test @@ -12,7 +13,7 @@ def test_defaults(self) -> None: assert self.newsletter.DEFAULT_LISTS == self.newsletter.get_default_list_ids() assert self.newsletter.DEFAULT_LIST_ID == self.newsletter.get_default_list_id() - def assert_subscriptions(self, user, count): + def assert_subscriptions(self, user: User, count: int) -> None: subscriptions = self.newsletter.get_subscriptions(user) assert subscriptions.get("subscriptions") is not None subscribed = [sub for sub in subscriptions["subscriptions"] if sub.subscribed] From f7138d0f59ed7899b0dbbc444754b14a9145201c Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:27:02 -0700 Subject: [PATCH 047/103] ref(typing): type seventeen weaklist wildcard families Annotate the remaining helpers and narrow the dynamic payloads at their existing boundaries, then drop these wildcard exemptions. Full mypy passes. --- pyproject.toml | 17 ------------ src/sentry/debug/utils/packages.py | 18 +++++-------- .../integrations/claude_code/integration.py | 5 ++-- .../integrations/discord/requests/base.py | 4 +-- .../integrations/opsgenie/actions/form.py | 3 ++- .../opsgenie/actions/notification.py | 5 ++-- .../tasks/send_notifications_on_activity.py | 4 +-- src/sentry/lang/dart/utils.py | 4 +-- .../notification_action_request.py | 2 +- .../notification_action_response.py | 6 ++--- src/sentry/spans/log_analyzer/fetchers.py | 4 +-- .../integrations/example/test_integration.py | 6 ++--- .../integrations/slack/tasks/test_tasks.py | 5 ++-- .../slack/utils/test_mock_slack_response.py | 6 +++-- .../slack/webhooks/options_load/__init__.py | 26 ++++++++++--------- .../test_dynamic_assignment_dropdown.py | 5 +++- tests/sentry/quotas/test_base.py | 2 +- tests/sentry/quotas/test_redis.py | 5 +++- .../seer/assisted_query/test_traces_tools.py | 17 +++++++++--- .../sentry_metrics/querying/data/test_api.py | 4 +-- tests/sentry/tagstore/test_types.py | 5 ++-- tests/sentry/tsdb/test_snuba.py | 4 +-- .../eap/test_preprod_eap_integration.py | 4 +-- 23 files changed, 83 insertions(+), 78 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 06ad19112cd4..6326b87ba96b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -509,7 +509,6 @@ module = [ "sentry.db.models.fields.uuid", "sentry.db.postgres.*", "sentry.db.router", - "sentry.debug.*", "sentry.debug_files.*", "sentry.discover.arithmetic", "sentry.discover.dataset_split", @@ -547,10 +546,8 @@ module = [ "sentry.integrations.aws_lambda.*", "sentry.integrations.bitbucket.*", "sentry.integrations.bitbucket_server.*", - "sentry.integrations.claude_code.*", "sentry.integrations.cursor.*", "sentry.integrations.data_forwarding.*", - "sentry.integrations.discord.requests.*", "sentry.integrations.example.*", "sentry.integrations.github.installation", "sentry.integrations.github.integration", @@ -574,12 +571,10 @@ module = [ "sentry.integrations.msteams.utils", "sentry.integrations.msteams.webhook", "sentry.integrations.on_call.*", - "sentry.integrations.opsgenie.actions.*", "sentry.integrations.pipeline", "sentry.integrations.slack.actions.*", "sentry.integrations.slack.integration", "sentry.integrations.slack.message_builder.*", - "sentry.integrations.slack.tasks.*", "sentry.integrations.tasks.*", "sentry.integrations.time_utils", "sentry.integrations.vsts.client", @@ -593,7 +588,6 @@ module = [ "sentry.issues.highlights", "sentry.issues.issue_search", "sentry.killswitches", - "sentry.lang.dart.*", "sentry.lang.java.view_hierarchies", "sentry.lang.javascript.*", "sentry.lang.native.*", @@ -646,7 +640,6 @@ module = [ "sentry.new_migrations.*", "sentry.newsletter.*", "sentry.notifications.api.endpoints.*", - "sentry.notifications.api.serializers.*", "sentry.notifications.models.*", "sentry.onboarding_tasks.*", "sentry.options.manager", @@ -704,7 +697,6 @@ module = [ "sentry.snuba.sessions_v2", "sentry.snuba.spans_metrics", "sentry.spans.consumers.process.*", - "sentry.spans.log_analyzer.*", "sentry.stacktraces.functions", "sentry.stacktraces.processing", "sentry.statistical_detectors.*", @@ -995,7 +987,6 @@ module = [ "tests.sentry.integrations.claude_code.*", "tests.sentry.integrations.cursor.*", "tests.sentry.integrations.data_forwarding.*", - "tests.sentry.integrations.example.*", "tests.sentry.integrations.github.*", "tests.sentry.integrations.github_copilot.*", "tests.sentry.integrations.github_enterprise.*", @@ -1021,7 +1012,6 @@ module = [ "tests.sentry.integrations.slack", "tests.sentry.integrations.slack.notifications.*", "tests.sentry.integrations.slack.service.*", - "tests.sentry.integrations.slack.tasks.*", "tests.sentry.integrations.slack.test_integration", "tests.sentry.integrations.slack.test_link_identity", "tests.sentry.integrations.slack.test_link_team", @@ -1035,7 +1025,6 @@ module = [ "tests.sentry.integrations.slack.webhooks", "tests.sentry.integrations.slack.webhooks.actions.*", "tests.sentry.integrations.slack.webhooks.events.*", - "tests.sentry.integrations.slack.webhooks.options_load.*", "tests.sentry.integrations.tasks.*", "tests.sentry.integrations.test_base", "tests.sentry.integrations.test_client", @@ -1151,7 +1140,6 @@ module = [ "tests.sentry.profiles.test_task", "tests.sentry.profiles.test_utils", "tests.sentry.projectoptions.*", - "tests.sentry.quotas.*", "tests.sentry.receivers", "tests.sentry.receivers.test_analytics", "tests.sentry.receivers.test_core", @@ -1191,7 +1179,6 @@ module = [ "tests.sentry.search.*", "tests.sentry.seer", "tests.sentry.seer.agent.*", - "tests.sentry.seer.assisted_query.*", "tests.sentry.seer.autofix.*", "tests.sentry.seer.endpoints.*", "tests.sentry.seer.entrypoints.*", @@ -1210,7 +1197,6 @@ module = [ "tests.sentry.sentry_apps.test_sentry_app_installation_token_creator", "tests.sentry.sentry_apps.test_sentry_app_updater", "tests.sentry.sentry_apps.test_webhooks", - "tests.sentry.sentry_metrics.querying.*", "tests.sentry.sentry_metrics.test_all_indexers", "tests.sentry.sentry_metrics.test_base_indexer", "tests.sentry.sentry_metrics.test_configuration", @@ -1265,7 +1251,6 @@ module = [ "tests.sentry.stacktraces.*", "tests.sentry.statistical_detectors.*", "tests.sentry.sudo.*", - "tests.sentry.tagstore.*", "tests.sentry.tasks", "tests.sentry.tasks.seer", "tests.sentry.tasks.seer.agentic_triage.*", @@ -1325,7 +1310,6 @@ module = [ "tests.sentry.test_viewer_context_jwt", "tests.sentry.test_wsgi", "tests.sentry.toolbar.*", - "tests.sentry.tsdb.*", "tests.sentry.uptime", "tests.sentry.uptime.consumers.*", "tests.sentry.uptime.endpoints.*", @@ -1450,7 +1434,6 @@ module = [ "tests.snuba.api", "tests.snuba.api.endpoints.*", "tests.snuba.billing.*", - "tests.snuba.preprod.*", "tests.snuba.search.*", "tests.snuba.sessions.*", "tests.snuba.tagstore.*", diff --git a/src/sentry/debug/utils/packages.py b/src/sentry/debug/utils/packages.py index ef69e2050607..c71c9198693c 100644 --- a/src/sentry/debug/utils/packages.py +++ b/src/sentry/debug/utils/packages.py @@ -2,8 +2,8 @@ import sys -def get_package_version(module_name, app): - version = None +def get_package_version(module_name: str, app: object) -> str | None: + version: object = None # Try to pull version from importlib.metadata first # as it is able to detect version tagged with egg_info -b @@ -12,14 +12,10 @@ def get_package_version(module_name, app): except Exception: pass - if hasattr(app, "get_version"): - version = app.get_version - elif hasattr(app, "__version__"): - version = app.__version__ - elif hasattr(app, "VERSION"): - version = app.VERSION - elif hasattr(app, "version"): - version = app.version + for attr in ("get_version", "__version__", "VERSION", "version"): + if hasattr(app, attr): + version = getattr(app, attr) + break if callable(version): try: @@ -39,7 +35,7 @@ def get_package_version(module_name, app): return str(version) -def get_all_package_versions(): +def get_all_package_versions() -> dict[str, str]: packages = {} for module_name, app in sys.modules.copy().items(): # ignore items that look like submodules diff --git a/src/sentry/integrations/claude_code/integration.py b/src/sentry/integrations/claude_code/integration.py index de5b2bf5c1ea..729c98007dd7 100644 --- a/src/sentry/integrations/claude_code/integration.py +++ b/src/sentry/integrations/claude_code/integration.py @@ -42,6 +42,7 @@ from sentry.seer.autofix.utils import CodingAgentState from sentry.shared_integrations.exceptions import IntegrationConfigurationError from sentry.utils.imports import import_string +from sentry.utils.locking.lock import Lock logger = logging.getLogger(__name__) @@ -155,7 +156,7 @@ def _delete_legacy_integrations(organization_id: int, external_id: str) -> None: integration.delete() -class ClaudeCodeApiKeySerializer(CamelSnakeSerializer): +class ClaudeCodeApiKeySerializer(CamelSnakeSerializer[dict[str, str]]): api_key = CharField(required=True, max_length=255) @@ -306,7 +307,7 @@ def _get_metadata(self) -> ClaudeCodeIntegrationMetadata: """Parse and return the integration metadata.""" return ClaudeCodeIntegrationMetadata.parse_obj(self.model.metadata or {}) - def _vault_metadata_lock(self): + def _vault_metadata_lock(self) -> Lock: return locks.get( f"claude_code:vault:{self.model.id}", duration=VAULT_METADATA_LOCK_DURATION_S, diff --git a/src/sentry/integrations/discord/requests/base.py b/src/sentry/integrations/discord/requests/base.py index 5f49d95a7b97..98c56be98f40 100644 --- a/src/sentry/integrations/discord/requests/base.py +++ b/src/sentry/integrations/discord/requests/base.py @@ -135,7 +135,7 @@ def response_url(self) -> str | None: return None return f"{DISCORD_BASE_URL}/webhooks/{application_id}/{token}" - def _get_context(self): + def _get_context(self) -> None: context = integration_service.get_integration_identity_context( integration_provider=IntegrationProviderSlug.DISCORD.value, integration_external_id=self.guild_id, @@ -239,7 +239,7 @@ def has_identity(self) -> bool: def _log_request(self) -> None: self._info("discord.request") - def _info(self, key: str, extra=None) -> None: + def _info(self, key: str, extra: dict[str, object] | None = None) -> None: if not extra: extra = {**self.logging_data} logger.info(key, extra=extra) diff --git a/src/sentry/integrations/opsgenie/actions/form.py b/src/sentry/integrations/opsgenie/actions/form.py index a750ac4968be..3ad370f79651 100644 --- a/src/sentry/integrations/opsgenie/actions/form.py +++ b/src/sentry/integrations/opsgenie/actions/form.py @@ -36,7 +36,8 @@ class OpsgenieNotifyTeamForm(forms.Form): account = forms.ChoiceField(choices=(), widget=forms.Select()) team = forms.ChoiceField(required=False, choices=(), widget=forms.Select()) - def __init__(self, *args, **kwargs): + # justification: Django form kwargs are dynamic. + def __init__(self, *args: Any, **kwargs: Any) -> None: self.org_id = kwargs.pop("org_id") self._integrations = [(i.id, i.name) for i in kwargs.pop("integrations")] self._teams = kwargs.pop("teams") diff --git a/src/sentry/integrations/opsgenie/actions/notification.py b/src/sentry/integrations/opsgenie/actions/notification.py index 30337788f502..3e5ea6b81109 100644 --- a/src/sentry/integrations/opsgenie/actions/notification.py +++ b/src/sentry/integrations/opsgenie/actions/notification.py @@ -2,7 +2,7 @@ import logging from collections.abc import Generator, Sequence -from typing import cast +from typing import Any, cast import sentry_sdk @@ -32,7 +32,8 @@ class OpsgenieNotifyTeamAction(IntegrationEventAction): provider = IntegrationProviderSlug.OPSGENIE.value integration_key = "account" - def __init__(self, *args, **kwargs): + # justification: Django form kwargs are dynamic. + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.form_fields = { "account": { diff --git a/src/sentry/integrations/slack/tasks/send_notifications_on_activity.py b/src/sentry/integrations/slack/tasks/send_notifications_on_activity.py index 6ec5305e9de6..bc5c10017e20 100644 --- a/src/sentry/integrations/slack/tasks/send_notifications_on_activity.py +++ b/src/sentry/integrations/slack/tasks/send_notifications_on_activity.py @@ -24,7 +24,7 @@ processing_deadline_duration=30, silo_mode=SiloMode.CELL, ) -def send_activity_notifications_to_slack_threads(activity_id) -> None: +def send_activity_notifications_to_slack_threads(activity_id: int) -> None: log_params = {"activity_id": activity_id} try: @@ -55,7 +55,7 @@ def send_activity_notifications_to_slack_threads(activity_id) -> None: ) -def activity_created_receiver(instance, created, **kwargs) -> None: +def activity_created_receiver(instance: Activity, created: bool, **kwargs: object) -> None: """ If an activity is created for an issue, this will trigger, and we can kick off an async process """ diff --git a/src/sentry/lang/dart/utils.py b/src/sentry/lang/dart/utils.py index 5ea0a016ea81..084846ccfe1f 100644 --- a/src/sentry/lang/dart/utils.py +++ b/src/sentry/lang/dart/utils.py @@ -66,7 +66,7 @@ def get_debug_meta_image_ids(event: dict[str, Any]) -> set[str]: } -def generate_dart_symbols_map(debug_ids: list[str], project: Project): +def generate_dart_symbols_map(debug_ids: list[str], project: Project) -> dict[str, str] | None: """ Fetches and returns the dart symbols mapping for the first available debug_id. There should only be one mapping file per Flutter build, so we return the first mapping found. @@ -159,7 +159,7 @@ def replace_symbol(match: re.Match[str]) -> str: exception["value"] = new_value -def has_native_frames_in_stacktraces(data) -> bool: +def has_native_frames_in_stacktraces(data: Mapping[str, object]) -> bool: for stacktrace_info in find_stacktraces_in_data(data): frames = stacktrace_info.get_frames() if frames and any(frame.get("platform") == "native" for frame in frames): diff --git a/src/sentry/notifications/api/serializers/notification_action_request.py b/src/sentry/notifications/api/serializers/notification_action_request.py index f5ca0a341625..55a9cc368f20 100644 --- a/src/sentry/notifications/api/serializers/notification_action_request.py +++ b/src/sentry/notifications/api/serializers/notification_action_request.py @@ -45,7 +45,7 @@ class NotificationActionInputData(TypedDict, total=False): target_type: int -class NotificationActionSerializer(CamelSnakeModelSerializer): +class NotificationActionSerializer(CamelSnakeModelSerializer[NotificationAction]): """ Django Rest Framework serializer for incoming NotificationAction API payloads """ diff --git a/src/sentry/notifications/api/serializers/notification_action_response.py b/src/sentry/notifications/api/serializers/notification_action_response.py index fd2e26680ec6..f011d34529e7 100644 --- a/src/sentry/notifications/api/serializers/notification_action_response.py +++ b/src/sentry/notifications/api/serializers/notification_action_response.py @@ -1,5 +1,5 @@ -from collections.abc import Sequence -from typing import TypedDict +from collections.abc import Mapping, Sequence +from typing import Any, TypedDict from django.contrib.auth.models import AnonymousUser @@ -63,7 +63,7 @@ def get_attrs( def serialize( self, obj: NotificationAction, - attrs, + attrs: Mapping[str, Any], # justification: serializer attrs are dynamically keyed. user: User | RpcUser | AnonymousUser, **kwargs: object, ) -> OutgoingNotificationActionResponse: diff --git a/src/sentry/spans/log_analyzer/fetchers.py b/src/sentry/spans/log_analyzer/fetchers.py index ab0ab75d3a06..b05f3baa0b7a 100644 --- a/src/sentry/spans/log_analyzer/fetchers.py +++ b/src/sentry/spans/log_analyzer/fetchers.py @@ -164,7 +164,7 @@ def fetch_logs_from_file( def _parse_buffer_file_entries( - raw_entries: list[dict], + raw_entries: list[dict[str, Any]], # justification: decoded log JSON entries. start_time: datetime | None, end_time: datetime | None, consumer: str | None, @@ -194,7 +194,7 @@ def _parse_buffer_file_entries( def _parse_flusher_file_entries( - raw_entries: list[dict], + raw_entries: list[dict[str, Any]], # justification: decoded log JSON entries. start_time: datetime | None, end_time: datetime | None, consumer: str | None, diff --git a/tests/sentry/integrations/example/test_integration.py b/tests/sentry/integrations/example/test_integration.py index 0fb3461feec7..2bf39e5596e3 100644 --- a/tests/sentry/integrations/example/test_integration.py +++ b/tests/sentry/integrations/example/test_integration.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch import responses from django.urls import reverse @@ -17,7 +17,7 @@ class ExampleIntegrationTest(APITestCase): endpoint = "sentry-api-0-organization-pipeline" - def setUp(self): + def setUp(self) -> None: super().setUp() self.login_as(self.user) @@ -77,7 +77,7 @@ def test_name_required(self) -> None: @patch( "sentry.api.endpoints.organization_pipeline.initialize_integration_pipeline", ) - def test_initialize_via_api(self, mock_init) -> None: + def test_initialize_via_api(self, mock_init: MagicMock) -> None: pipeline = self._init_pipeline_in_session() pipeline.set_api_mode() mock_init.return_value = pipeline diff --git a/tests/sentry/integrations/slack/tasks/test_tasks.py b/tests/sentry/integrations/slack/tasks/test_tasks.py index 4dee7d430d7a..46b610d3aeaa 100644 --- a/tests/sentry/integrations/slack/tasks/test_tasks.py +++ b/tests/sentry/integrations/slack/tasks/test_tasks.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from unittest.mock import MagicMock, patch import orjson @@ -19,7 +20,7 @@ def setUp(self) -> None: self.integration = install_slack(self.organization) @pytest.fixture(autouse=True) - def mock_chat_scheduleMessage(self): + def mock_chat_scheduleMessage(self) -> Iterator[None]: with mock_slack_response( "chat_scheduleMessage", body={"ok": True, "channel": "chan-id", "scheduled_message_id": "Q1298393284"}, @@ -27,7 +28,7 @@ def mock_chat_scheduleMessage(self): yield @pytest.fixture(autouse=True) - def mock_chat_deleteScheduledMessage(self): + def mock_chat_deleteScheduledMessage(self) -> Iterator[None]: with mock_slack_response( "chat_deleteScheduledMessage", body={"ok": True} ) as self.mock_delete: diff --git a/tests/sentry/integrations/slack/utils/test_mock_slack_response.py b/tests/sentry/integrations/slack/utils/test_mock_slack_response.py index 10aa1ea4d13c..bb259ad11a86 100644 --- a/tests/sentry/integrations/slack/utils/test_mock_slack_response.py +++ b/tests/sentry/integrations/slack/utils/test_mock_slack_response.py @@ -1,4 +1,6 @@ -from unittest.mock import patch +from __future__ import annotations + +from unittest.mock import MagicMock, _patch, patch from slack_sdk.web.slack_response import SlackResponse @@ -11,7 +13,7 @@ def mock_slack_response( api_url: str | None = None, req_args: dict[str, object] | None = None, headers: dict[str, object] | None = None, -): +) -> _patch[MagicMock]: if api_url is None: api_url = f"https://slack.com/api/{method_name}" diff --git a/tests/sentry/integrations/slack/webhooks/options_load/__init__.py b/tests/sentry/integrations/slack/webhooks/options_load/__init__.py index 373232eb34b0..ac3bbf1a6931 100644 --- a/tests/sentry/integrations/slack/webhooks/options_load/__init__.py +++ b/tests/sentry/integrations/slack/webhooks/options_load/__init__.py @@ -1,6 +1,8 @@ -from unittest.mock import patch +from collections.abc import Mapping +from unittest.mock import MagicMock, patch import orjson +from rest_framework.response import Response from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.slack import install_slack @@ -18,15 +20,17 @@ def setUp(self) -> None: ) def post_webhook( self, - check_signing_secret_mock, - substring="", - type="block_suggestion", - data=None, - team_id="TXXXXXXX1", - slack_user=None, - original_message=None, - action_id="assign", - ): + check_signing_secret_mock: MagicMock, + substring: str = "", + type: str = "block_suggestion", + data: Mapping[str, object] | None = None, + team_id: str = "TXXXXXXX1", + slack_user: Mapping[str, object] | None = None, + original_message: Mapping[str, object] | None = None, + action_id: str = "assign", + ) -> Response: + payload: dict[str, object] = {} + if slack_user is None: slack_user = { "id": self.external_id, @@ -38,8 +42,6 @@ def post_webhook( if original_message is None: original_message = {} - payload = {} - if type == "block_suggestion": payload = { "payload": orjson.dumps( diff --git a/tests/sentry/integrations/slack/webhooks/options_load/test_dynamic_assignment_dropdown.py b/tests/sentry/integrations/slack/webhooks/options_load/test_dynamic_assignment_dropdown.py index 5ec82d6e526d..03b526507251 100644 --- a/tests/sentry/integrations/slack/webhooks/options_load/test_dynamic_assignment_dropdown.py +++ b/tests/sentry/integrations/slack/webhooks/options_load/test_dynamic_assignment_dropdown.py @@ -1,3 +1,5 @@ +from typing import Any + import orjson from sentry.constants import ObjectStatus @@ -29,7 +31,8 @@ def setUp(self) -> None: ] }, } - self.original_message: dict = { + # justification: Slack message payload is dynamic JSON. + self.original_message: dict[str, Any] = { "type": "message", "text": "[internal] IntegrationError", "blocks": [ diff --git a/tests/sentry/quotas/test_base.py b/tests/sentry/quotas/test_base.py index 5b765e6c1a24..065ac3646cb7 100644 --- a/tests/sentry/quotas/test_base.py +++ b/tests/sentry/quotas/test_base.py @@ -142,7 +142,7 @@ def test_check_accept_monitor_checkin(self) -> None: ), ], ) -def test_quotas_to_json(obj, json) -> None: +def test_quotas_to_json(obj: QuotaConfig, json: dict[str, object]) -> None: assert obj.to_json() == json diff --git a/tests/sentry/quotas/test_redis.py b/tests/sentry/quotas/test_redis.py index a8d767552d7d..e61effba85f4 100644 --- a/tests/sentry/quotas/test_redis.py +++ b/tests/sentry/quotas/test_redis.py @@ -4,6 +4,7 @@ from unittest import mock import pytest +import rb from sentry.constants import DataCategory from sentry.quotas.base import ( @@ -23,7 +24,7 @@ class RedisQuotaTest(TestCase): @cached_property - def quota(self): + def quota(self) -> RedisQuota: return RedisQuota() def test_redis_quota_serialize(self) -> None: @@ -270,6 +271,7 @@ def test_refund_defaults(self, mock_get_quotas: mock.MagicMock) -> None: org_id = self.project.organization.pk self.quota.refund(self.project, timestamp=timestamp) + assert isinstance(self.quota.cluster, rb.Cluster) client = self.quota.cluster.get_local_client_for_key(str(self.project.organization.pk)) error_keys = client.keys(f"r:quota:p{{{org_id}}}*:*") @@ -320,6 +322,7 @@ def test_refund_categories(self, mock_get_quotas: mock.MagicMock) -> None: self.quota.refund( self.project, timestamp=timestamp, category=DataCategory.ATTACHMENT, quantity=100 ) + assert isinstance(self.quota.cluster, rb.Cluster) client = self.quota.cluster.get_local_client_for_key(str(self.project.organization.pk)) error_keys = client.keys(f"r:quota:p{{{org_id}}}*:*") diff --git a/tests/sentry/seer/assisted_query/test_traces_tools.py b/tests/sentry/seer/assisted_query/test_traces_tools.py index db4a647dbcca..92d8d1488028 100644 --- a/tests/sentry/seer/assisted_query/test_traces_tools.py +++ b/tests/sentry/seer/assisted_query/test_traces_tools.py @@ -1,11 +1,17 @@ +from collections.abc import Mapping from unittest.mock import MagicMock, patch from sentry.seer.assisted_query.traces_tools import get_attribute_names from sentry.testutils.cases import TestCase -def _attribute(name, attr_type="string", source="user", context=None): - attribute = { +def _attribute( + name: str, + attr_type: str = "string", + source: str = "user", + context: Mapping[str, object] | None = None, +) -> dict[str, object]: + attribute: dict[str, object] = { "key": name, "name": name, "attributeType": attr_type, @@ -22,7 +28,12 @@ def setUp(self) -> None: self.org = self.create_organization() self.project = self.create_project(organization=self.org) - def _mock_responses(self, mock_client_cls, string_attrs, number_attrs=None): + def _mock_responses( + self, + mock_client_cls: MagicMock, + string_attrs: list[dict[str, object]], + number_attrs: list[dict[str, object]] | None = None, + ) -> MagicMock: """Stub the two per-type calls get_attribute_names makes (string, then number).""" mock_client = mock_client_cls.return_value string_response, number_response = MagicMock(), MagicMock() diff --git a/tests/sentry/sentry_metrics/querying/data/test_api.py b/tests/sentry/sentry_metrics/querying/data/test_api.py index e819b976a62f..ae553b906fbf 100644 --- a/tests/sentry/sentry_metrics/querying/data/test_api.py +++ b/tests/sentry/sentry_metrics/querying/data/test_api.py @@ -86,7 +86,7 @@ def setUp(self) -> None: self.query_transformer = MetricsAPIQueryResultsTransformer() - def now(self): + def now(self) -> datetime: return MOCK_DATETIME def ts(self, dt: datetime) -> int: @@ -1485,7 +1485,7 @@ def test_query_with_basic_formula_and_coefficient_operators(self) -> None: assert meta[0][1]["unit"] == expected_unit assert meta[0][1]["scaling_factor"] is None - def setup_project_with_metrics(self, name: str): + def setup_project_with_metrics(self, name: str) -> Project: new_project = self.create_project(name=name) for value, transaction, platform, env, time in ( (1, "/hello", "android", "prod", self.now()), diff --git a/tests/sentry/tagstore/test_types.py b/tests/sentry/tagstore/test_types.py index bdd1f6edd0ef..329db9e13511 100644 --- a/tests/sentry/tagstore/test_types.py +++ b/tests/sentry/tagstore/test_types.py @@ -1,6 +1,7 @@ from __future__ import annotations import pickle +from typing import Any import pytest @@ -10,13 +11,13 @@ @classes -def test_pickle(cls) -> None: +def test_pickle(cls: type[Any]) -> None: value = cls(**{name: 1 for name in cls.__slots__}) assert pickle.loads(pickle.dumps(value)) == value @classes -def test_sorting(cls) -> None: +def test_sorting(cls: type[Any]) -> None: value1 = cls(**{name: 1 for name in cls.__slots__}) value2 = cls(**{name: 2 for name in cls.__slots__}) diff --git a/tests/sentry/tsdb/test_snuba.py b/tests/sentry/tsdb/test_snuba.py index c4a21af4401f..82b69761275f 100644 --- a/tests/sentry/tsdb/test_snuba.py +++ b/tests/sentry/tsdb/test_snuba.py @@ -7,12 +7,12 @@ from sentry.utils.outcomes import Outcome -def floor_to_hour_epoch(value): +def floor_to_hour_epoch(value: datetime) -> int: value = value.replace(minute=0, second=0, microsecond=0) return int(value.timestamp()) -def floor_to_10s_epoch(value): +def floor_to_10s_epoch(value: datetime) -> int: seconds = value.second floored_second = 10 * (seconds // 10) diff --git a/tests/snuba/preprod/eap/test_preprod_eap_integration.py b/tests/snuba/preprod/eap/test_preprod_eap_integration.py index 9854acb52939..362a5082b904 100644 --- a/tests/snuba/preprod/eap/test_preprod_eap_integration.py +++ b/tests/snuba/preprod/eap/test_preprod_eap_integration.py @@ -18,7 +18,7 @@ class PreprodEAPIntegrationTest(TestCase, SnubaTestCase): - def test_write_and_read_size_metric_round_trip(self): + def test_write_and_read_size_metric_round_trip(self) -> None: commit_comparison = CommitComparison.objects.create( organization_id=self.organization.id, head_sha="abc123def456", @@ -127,7 +127,7 @@ def test_write_and_read_size_metric_round_trip(self): assert response.column_values[columns["git_head_sha"]].results[0].val_str == "abc123def456" assert response.column_values[columns["git_head_ref"]].results[0].val_str == "main" - def test_write_multiple_size_metrics_same_artifact(self): + def test_write_multiple_size_metrics_same_artifact(self) -> None: artifact = self.create_preprod_artifact( project=self.project, state=PreprodArtifact.ArtifactState.PROCESSED, From b600d9afab355796612f2c52e0d647c924d1007e Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:29:26 -0700 Subject: [PATCH 048/103] ref(typing): graduate lane 12 weaklist modules Annotate the twelve weaklisted modules so they pass strict mypy without the disallow_untyped_defs override. GzippedDictField's nullable generic requires matching the GroupTombstone field annotation and accessors. --- .../project_performance_general_settings.py | 8 +- .../api/serializers/models/pullrequest.py | 39 +++++--- src/sentry/apidocs/hooks.py | 10 ++- src/sentry/db/models/fields/gzippeddict.py | 16 ++-- src/sentry/integrations/msteams/client.py | 40 +++++++-- src/sentry/models/auditlogentry.py | 30 +++++-- src/sentry/models/grouptombstone.py | 8 +- src/sentry/models/projectkey.py | 15 ++-- src/sentry/relay/datascrubbing.py | 30 ++++--- src/sentry/snuba/spans_metrics.py | 88 ++++++++++--------- src/sentry/utils/managers.py | 15 ++-- src/sentry/web/client_config.py | 16 ++-- src/sentry/web/frontend/oauth_token.py | 37 +++++--- 13 files changed, 223 insertions(+), 129 deletions(-) diff --git a/src/sentry/api/endpoints/project_performance_general_settings.py b/src/sentry/api/endpoints/project_performance_general_settings.py index ecc4edebc2e3..40cfc83e5288 100644 --- a/src/sentry/api/endpoints/project_performance_general_settings.py +++ b/src/sentry/api/endpoints/project_performance_general_settings.py @@ -65,7 +65,7 @@ class ProjectPerformanceGeneralSettingsEndpoint(ProjectEndpoint): 404: RESPONSE_NOT_FOUND, }, ) - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project: Project) -> Response: """ Return the general performance settings for a project. """ @@ -108,16 +108,16 @@ def post(self, request: Request, project: Project) -> Response: self.update_settings(project, request.data) return Response(status=status.HTTP_204_NO_CONTENT) - def has_feature(self, project, request) -> bool: + def has_feature(self, project: Project, request: Request) -> bool: return features.has( "organizations:performance-view", project.organization, actor=request.user ) - def get_current_settings(self, project: Project): + def get_current_settings(self, project: Project) -> dict[str, object]: return project.get_option( SETTINGS_PROJECT_OPTION_KEY, DEFAULT_PROJECT_PERFORMANCE_GENERAL_SETTINGS ) - def update_settings(self, project: Project, new_settings: dict): + def update_settings(self, project: Project, new_settings: dict[str, object]) -> None: current_settings = self.get_current_settings(project) project.update_option(SETTINGS_PROJECT_OPTION_KEY, {**current_settings, **new_settings}) diff --git a/src/sentry/api/serializers/models/pullrequest.py b/src/sentry/api/serializers/models/pullrequest.py index a0e5f655deb5..35d358a1c4b6 100644 --- a/src/sentry/api/serializers/models/pullrequest.py +++ b/src/sentry/api/serializers/models/pullrequest.py @@ -1,6 +1,6 @@ from collections.abc import Mapping, Sequence from datetime import datetime -from typing import Literal, TypedDict +from typing import Literal, TypedDict, cast from django.contrib.auth.models import AnonymousUser from django.db.models import Prefetch, prefetch_related_objects @@ -82,7 +82,9 @@ class LinkedPullRequestResponse(PullRequestSerializerResponse): reviewStatus: AggregateReviewStatus | None -def get_users_for_pull_requests(item_list, user=None): +def get_users_for_pull_requests( + item_list: Sequence[PullRequest], user: User | RpcUser | AnonymousUser | None = None +) -> Mapping[str, Author]: authors = list( CommitAuthor.objects.filter(id__in=[i.author_id for i in item_list if i.author_id]) ) @@ -96,16 +98,21 @@ def get_users_for_pull_requests(item_list, user=None): @register(PullRequest) class PullRequestSerializer(Serializer[PullRequestSerializerResponse]): - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[PullRequest], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[PullRequest, dict[str, object]]: users_by_author = get_users_for_pull_requests(item_list, user) repositories = list(Repository.objects.filter(id__in=[c.repository_id for c in item_list])) repository_map = {repository.id: repository for repository in repositories} serialized_repos = {r["id"]: r for r in serialize(repositories, user)} - result = {} + result: dict[PullRequest, dict[str, object]] = {} for item in item_list: repository_id = str(item.repository_id) - external_url = "" + external_url: str | None = "" if item.repository_id in repository_map: external_url = item.get_external_url() result[item] = { @@ -119,7 +126,7 @@ def get_attrs(self, item_list, user, **kwargs): def serialize( self, obj: PullRequest, - attrs, + attrs: Mapping[str, object], user: User | RpcUser | AnonymousUser, **kwargs: object, ) -> PullRequestSerializerResponse: @@ -130,9 +137,9 @@ def serialize( "dateCreated": obj.date_added, "mergedAt": obj.merged_at, "status": get_stored_pull_request_status(obj), - "repository": attrs["repository"], - "author": attrs["user"], - "externalUrl": attrs["external_url"], + "repository": cast(RepositorySerializerResponse, attrs["repository"]), + "author": cast(Author, attrs["user"]), + "externalUrl": cast(str, attrs["external_url"]), } @@ -177,7 +184,12 @@ def __init__( self.status_by_pr_id = status_by_pr_id self.checks_and_review_by_pr_id = checks_and_review_by_pr_id - def get_attrs(self, item_list, user, **kwargs): + def get_attrs( + self, + item_list: Sequence[PullRequest], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[PullRequest, dict[str, object]]: attrs = super().get_attrs(item_list, user) prefetch_related_objects( item_list, @@ -188,20 +200,21 @@ def get_attrs(self, item_list, user, **kwargs): ), ) for item in item_list: - attrs[item]["attribution"] = _serialize_attribution(item.valid_attributions) + attributions: Sequence[PullRequestAttribution] = getattr(item, "valid_attributions") + attrs[item]["attribution"] = _serialize_attribution(attributions) return attrs def serialize( self, obj: PullRequest, - attrs, + attrs: Mapping[str, object], user: User | RpcUser | AnonymousUser, **kwargs: object, ) -> LinkedPullRequestResponse: checks_and_review = self.checks_and_review_by_pr_id.get(obj.id, PullRequestStatusResult()) return { **super().serialize(obj, attrs, user, **kwargs), - "attribution": attrs["attribution"], + "attribution": cast(LinkedPullRequestAttributionResponse | None, attrs["attribution"]), "dateLinked": self.date_linked_by_pr_id[obj.id], "status": self.status_by_pr_id[obj.id], "checksStatus": checks_and_review.checks, diff --git a/src/sentry/apidocs/hooks.py b/src/sentry/apidocs/hooks.py index f5755a9d7296..913d932cc96c 100644 --- a/src/sentry/apidocs/hooks.py +++ b/src/sentry/apidocs/hooks.py @@ -48,7 +48,7 @@ class EndpointRegistryType(TypedDict): ] -def __get_line_count_for_team_stats(team_stats: Mapping): +def __get_line_count_for_team_stats(team_stats: Mapping[ApiPublishStatus, set[str]]) -> int: """ Returns number of lines it takes to write ownership for each team. For example returns 15 for: @@ -71,12 +71,14 @@ def __get_line_count_for_team_stats(team_stats: Mapping): return line_count -def __write_ownership_data(ownership_data: dict[ApiOwner, dict]): +def __write_ownership_data( + ownership_data: dict[ApiOwner, dict[ApiPublishStatus, set[str]]], +) -> None: """ Writes API ownership for all the teams in _OWNERSHIP_FILE. This file is used by Sentaur slack bot to inform teams on status of their APIs """ - processed_data = {} + processed_data: dict[str, dict[str, int | list[str]]] = {} index = 2 for team in ownership_data: # sorting APIs list so it doesn't trigger file change on every commit @@ -186,7 +188,7 @@ def custom_preprocessing_hook(endpoints: Any) -> Any: # TODO: organize method, _IDEMPOTENT_OPERATIONS.clear() filtered = [] - ownership_data: dict[ApiOwner, dict] = {} + ownership_data: dict[ApiOwner, dict[ApiPublishStatus, set[str]]] = {} for path, path_regex, method, callback in endpoints: # Collect servers from endpoint class for postprocessing endpoint_servers = getattr(callback.view_class, "servers", None) diff --git a/src/sentry/db/models/fields/gzippeddict.py b/src/sentry/db/models/fields/gzippeddict.py index df7edb275580..900c2c8f600b 100644 --- a/src/sentry/db/models/fields/gzippeddict.py +++ b/src/sentry/db/models/fields/gzippeddict.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import cast + from django.db.models import Model, TextField from sentry.db.models.utils import Creator @@ -8,7 +10,7 @@ __all__ = ("GzippedDictField",) -class GzippedDictField(TextField): +class GzippedDictField(TextField[dict[str, object] | None, dict[str, object] | None]): """ Slightly different from a JSONField in the sense that the default value is a dictionary. @@ -22,18 +24,18 @@ def contribute_to_class(self, cls: type[Model], name: str, private_only: bool = super().contribute_to_class(cls, name, private_only=private_only) setattr(cls, name, Creator(self)) - def to_python(self, value): + def to_python(self, value: object) -> object: try: if not value: return {} - return json.loads(value) + return json.loads(cast(str | bytes, value)) except (ValueError, TypeError): return value - def from_db_value(self, value, expression, connection): + def from_db_value(self, value: object, expression: object, connection: object) -> object: return self.to_python(value) - def get_prep_value(self, value): + def get_prep_value(self, value: object) -> str | None: if not value and self.null: # save ourselves some storage return None @@ -43,5 +45,5 @@ def get_prep_value(self, value): return None return json.dumps(value) - def value_to_string(self, obj): - return self.get_prep_value(self.value_from_object(obj)) + def value_to_string(self, obj: Model) -> str: + return cast(str, self.get_prep_value(self.value_from_object(obj))) diff --git a/src/sentry/integrations/msteams/client.py b/src/sentry/integrations/msteams/client.py index 569131741931..22e26e5288d3 100644 --- a/src/sentry/integrations/msteams/client.py +++ b/src/sentry/integrations/msteams/client.py @@ -5,7 +5,7 @@ from abc import ABC from collections.abc import Mapping from datetime import datetime, timezone -from typing import TypedDict, cast +from typing import NotRequired, TypedDict, cast from urllib.parse import urlencode from requests import PreparedRequest @@ -28,6 +28,32 @@ CLOCK_SKEW = 60 * 5 +class _TeamChannel(TypedDict, total=False): + id: str + name: str + + +class _TeamMember(TypedDict): + id: str + name: str + tenantId: str + + +class _PagedMembersResponse(TypedDict): + members: list[_TeamMember] + continuationToken: NotRequired[str] + + +class _TokenResponse(TypedDict): + access_token: str + expires_in: int + + +class _OpenIDConfig(TypedDict): + jwks_uri: str + id_token_signing_alg_values_supported: list[str] + + # MsTeamsClientABC abstract client does not handle setting the base url or auth token class MsTeamsClientABC(ApiClient, ABC): integration_name = IntegrationProviderSlug.MSTEAMS.value @@ -38,11 +64,13 @@ class MsTeamsClientABC(ApiClient, ABC): CONVERSATION_URL = "/v3/conversations" MEMBER_URL = "/v3/conversations/%s/pagedmembers" - def get_channel_list(self, team_id: str): + def get_channel_list(self, team_id: str) -> list[_TeamChannel]: resp = self.get(self.CHANNEL_URL % team_id) return resp.get("conversations") - def get_member_list(self, team_id: str, continuation_token: str | None = None): + def get_member_list( + self, team_id: str, continuation_token: str | None = None + ) -> _PagedMembersResponse: url = self.MEMBER_URL % team_id params: dict[str, int | str] = {"pageSize": 500} if continuation_token: @@ -112,7 +140,7 @@ def request( class MsTeamsClient(MsTeamsClientABC, IntegrationProxyClient): integration_name = IntegrationProviderSlug.MSTEAMS.value - def request(self, *args, **kwargs): + def request(self, *args: object, **kwargs: object) -> object: try: return super().request(*args, **kwargs) except ApiError as error: @@ -182,7 +210,7 @@ def __init__(self, client_id: str, client_secret: str) -> None: if tenant_id: self.base_url = f"https://login.microsoftonline.com/{tenant_id}" - def exchange_token(self): + def exchange_token(self) -> _TokenResponse: headers = {"Content-Type": "application/x-www-form-urlencoded"} data = { "client_id": self.client_id, @@ -238,5 +266,5 @@ class MsTeamsJwtClient(ApiClient): cache_time = 60 * 60 * 24 OPEN_ID_CONFIG_URL = "https://login.botframework.com/v1/.well-known/openidconfiguration" - def get_open_id_config(self): + def get_open_id_config(self) -> _OpenIDConfig: return self.get_cached(self.OPEN_ID_CONFIG_URL) diff --git a/src/sentry/models/auditlogentry.py b/src/sentry/models/auditlogentry.py index b357f01e25fd..d9d69f3dcf03 100644 --- a/src/sentry/models/auditlogentry.py +++ b/src/sentry/models/auditlogentry.py @@ -2,8 +2,11 @@ import logging import re +from collections.abc import Iterable +from typing import TYPE_CHECKING from django.db import models +from django.db.models.base import ModelBase from django.utils import timezone from sentry.audit_log.services.log import AuditLogEvent @@ -20,17 +23,20 @@ from sentry.db.models.fields.jsonfield import LegacyTextJSONField from sentry.users.services.user.service import user_service +if TYPE_CHECKING: + from sentry.users.models.user import User + MAX_ACTOR_LABEL_LENGTH = 64 logger = logging.getLogger(__name__) -def is_scim_token_actor(actor): +def is_scim_token_actor(actor: User) -> bool: scim_prefix = "scim-internal-integration-" return scim_prefix in actor.get_display_name() -def format_scim_token_actor_name(actor): +def format_scim_token_actor_name(actor: User) -> str: scim_regex = re.compile( r".*([0-9a-fA-F]{6})\-[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{7}" ) @@ -76,13 +82,25 @@ class Meta: __repr__ = sane_repr("organization_id", "type") - def save(self, *args, **kwargs): + def save( + self, + *, + force_insert: bool | tuple[ModelBase, ...] = False, + force_update: bool = False, + using: str | None = None, + update_fields: Iterable[str] | None = None, + ) -> None: # trim label to the max length self._apply_actor_label() self.actor_label = self.actor_label[:MAX_ACTOR_LABEL_LENGTH] if self.actor_label else "" - super().save(*args, **kwargs) + super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) - def _apply_actor_label(self): + def _apply_actor_label(self) -> None: if not self.actor_label: assert self.actor_id or self.actor_key or self.ip_address if self.actor_id: @@ -153,7 +171,7 @@ def from_event(cls, event: AuditLogEvent) -> AuditLogEntry: actor_key_id=event.actor_key_id, ) - def get_actor_name(self): + def get_actor_name(self) -> str | None: if self.actor: # fix display name if needed if is_scim_token_actor(self.actor): diff --git a/src/sentry/models/grouptombstone.py b/src/sentry/models/grouptombstone.py index f5017f8ba85f..af41b3713827 100644 --- a/src/sentry/models/grouptombstone.py +++ b/src/sentry/models/grouptombstone.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import cast from django.db import models from django.utils import timezone @@ -35,7 +35,7 @@ class GroupTombstone(Model): ) message = models.TextField() culprit = models.CharField(max_length=MAX_CULPRIT_LENGTH, blank=True, null=True) - data: models.Field[dict[str, Any] | None, dict[str, Any]] = GzippedDictField( + data: models.Field[dict[str, object] | None, dict[str, object] | None] = GzippedDictField( blank=True, null=True ) actor_id = BoundedPositiveIntegerField(null=True) @@ -52,7 +52,7 @@ def get_event_type(self): See ``sentry.eventtypes``. """ - return self.data.get("type", "default") + return cast(dict[str, object], self.data).get("type", "default") def get_event_metadata(self): """ @@ -60,4 +60,4 @@ def get_event_metadata(self): See ``sentry.eventtypes``. """ - return self.data["metadata"] + return cast(dict[str, object], self.data)["metadata"] diff --git a/src/sentry/models/projectkey.py b/src/sentry/models/projectkey.py index 0a625b46b2f2..d80b9d735898 100644 --- a/src/sentry/models/projectkey.py +++ b/src/sentry/models/projectkey.py @@ -34,6 +34,9 @@ from sentry.tasks.relay import schedule_invalidate_project_config if TYPE_CHECKING: + from rest_framework.request import Request + + from sentry.models.organization import Organization from sentry.models.project import Project _token_re = re.compile(r"^[a-f0-9]{32}$") @@ -57,7 +60,7 @@ def post_delete(self, instance: ProjectKey, **kwargs: object) -> None: public_key=instance.public_key, trigger="projectkey.post_delete" ) - def for_request(self, request): + def for_request(self, request: Request) -> models.QuerySet[ProjectKey]: """Return objects that the given request user is allowed to access""" from sentry.auth.superuser import is_active_superuser @@ -163,7 +166,7 @@ def looks_like_api_key(cls, key: str) -> bool: return bool(_token_re.match(key)) @classmethod - def from_dsn(cls, dsn): + def from_dsn(cls, dsn: str) -> ProjectKey: urlparts = urlparse(dsn) public_key = urlparts.username @@ -175,7 +178,7 @@ def from_dsn(cls, dsn): if not project_id.isdecimal() or int(project_id) > I64_MAX: raise ProjectKey.DoesNotExist("ProjectKey matching query does not exist.") - return ProjectKey.objects.get(public_key=public_key, project=project_id) + return ProjectKey.objects.get(public_key=public_key, project=int(project_id)) @classmethod def get_default(cls, project: Project | int) -> ProjectKey | None: @@ -186,7 +189,7 @@ def get_default(cls, project: Project | int) -> ProjectKey | None: ).first() @property - def is_active(self): + def is_active(self) -> bool: return self.status == ProjectKeyStatus.ACTIVE @property @@ -248,11 +251,11 @@ def get_dsn(self, domain: str | None = None, secure: bool = True, public: bool = return f"{urlparts.scheme}://{key}@{urlparts.netloc + urlparts.path}/{self.project_id}" @property - def organization_id(self): + def organization_id(self) -> int: return self.project.organization_id @property - def organization(self): + def organization(self) -> Organization: return self.project.organization @property diff --git a/src/sentry/relay/datascrubbing.py b/src/sentry/relay/datascrubbing.py index 8ebaa9c7c665..d9c81ff9d646 100644 --- a/src/sentry/relay/datascrubbing.py +++ b/src/sentry/relay/datascrubbing.py @@ -1,8 +1,8 @@ from __future__ import annotations import copy -from collections.abc import MutableMapping -from typing import TYPE_CHECKING, Any +from collections.abc import Iterator, Mapping, MutableMapping +from typing import TYPE_CHECKING, Any, TypedDict import orjson from rest_framework import serializers @@ -22,10 +22,16 @@ from sentry.models.project import Project -def get_pii_config(project): - def _decode(value): +class _PIIConfig(TypedDict, total=False): + rules: dict[str, object] + applications: dict[str, list[str]] + + +def get_pii_config(project: Project) -> _PIIConfig: + def _decode(value: str | None) -> _PIIConfig | None: if value: return safe_execute(orjson.loads, value) + return None # Order of merging is important here. We want to apply organization rules # before project rules. For example: @@ -47,9 +53,9 @@ def _decode(value): ) -def get_datascrubbing_settings(project): +def get_datascrubbing_settings(project: Project) -> dict[str, object]: org = project.organization - rv = {} + rv: dict[str, object] = {} exclude_fields_key = "sentry:safe_fields" rv["excludeFields"] = org.get_option(exclude_fields_key, []) + project.get_option( @@ -78,7 +84,7 @@ def get_datascrubbing_settings(project): return rv -def get_all_pii_configs(project): +def get_all_pii_configs(project: Project) -> Iterator[_PIIConfig]: # Note: This logic is duplicated in Relay store. pii_config = get_pii_config(project) if pii_config: @@ -110,14 +116,16 @@ def scrub_data(project: Project, event: MutableMapping[str, Any]) -> MutableMapp return event -def _merge_pii_configs(prefixes_and_configs: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]: +def _merge_pii_configs( + prefixes_and_configs: list[tuple[str, _PIIConfig | None]], +) -> _PIIConfig: """ Merge two PII configs into one, prefixing all custom rules with a prefix in the name. This is used to apply organization and project configs at once, and still get unique references to rule names. """ - merged_config: dict[str, Any] = {} + merged_config: _PIIConfig = {} for prefix, partial_config in prefixes_and_configs: if not partial_config: @@ -174,7 +182,9 @@ def validate_pii_selectors(selectors: list[str]) -> list[str]: return selectors -def _prefix_rule_references_in_rule(custom_rules, rule_def, prefix): +def _prefix_rule_references_in_rule( + custom_rules: Mapping[str, object], rule_def: object, prefix: str +) -> object: if not isinstance(rule_def, dict): return rule_def diff --git a/src/sentry/snuba/spans_metrics.py b/src/sentry/snuba/spans_metrics.py index 7ed44999561f..a43da40110ca 100644 --- a/src/sentry/snuba/spans_metrics.py +++ b/src/sentry/snuba/spans_metrics.py @@ -1,50 +1,56 @@ +from __future__ import annotations + import logging from datetime import timedelta +from typing import TYPE_CHECKING -from snuba_sdk import Column +from snuba_sdk import Column, Condition from sentry.search.events.builder.spans_metrics import ( SpansMetricsQueryBuilder, TimeseriesSpansMetricsQueryBuilder, TopSpansMetricsQueryBuilder, ) -from sentry.search.events.types import QueryBuilderConfig, SnubaParams +from sentry.search.events.types import EventsResponse, QueryBuilderConfig, SnubaParams from sentry.snuba import discover from sentry.snuba.dataset import Dataset from sentry.snuba.metrics.extraction import MetricSpecType from sentry.snuba.query_sources import QuerySource from sentry.utils.snuba import SnubaTSResult +if TYPE_CHECKING: + from sentry.models.organization import Organization + logger = logging.getLogger(__name__) def query( - selected_columns, - query, - snuba_params=None, - equations=None, - orderby=None, - offset=None, - limit=50, - auto_fields=False, - auto_aggregations=False, - include_equation_fields=False, - allow_metric_aggregates=False, - use_aggregate_conditions=False, - conditions=None, - functions_acl=None, - transform_alias_to_input_format=False, - sample=None, - has_metrics=False, - skip_tag_resolution=False, - extra_columns=None, - on_demand_metrics_enabled=False, + selected_columns: list[str], + query: str, + snuba_params: SnubaParams, + equations: list[str] | None = None, + orderby: list[str] | None = None, + offset: int | None = None, + limit: int = 50, + auto_fields: bool = False, + auto_aggregations: bool = False, + include_equation_fields: bool = False, + allow_metric_aggregates: bool = False, + use_aggregate_conditions: bool = False, + conditions: list[Condition] | None = None, + functions_acl: list[str] | None = None, + transform_alias_to_input_format: bool = False, + sample: float | None = None, + has_metrics: bool = False, + skip_tag_resolution: bool = False, + extra_columns: list[Column] | None = None, + on_demand_metrics_enabled: bool = False, on_demand_metrics_type: MetricSpecType | None = None, - fallback_to_transactions=False, + fallback_to_transactions: bool = False, query_source: QuerySource | None = None, *, referrer: str, -): +) -> EventsResponse: builder = SpansMetricsQueryBuilder( dataset=Dataset.PerformanceMetrics, params={}, @@ -82,7 +88,7 @@ def timeseries_query( snuba_params: SnubaParams, rollup: int, zerofill_results: bool = True, - allow_metric_aggregates=True, + allow_metric_aggregates: bool = True, comparison_delta: timedelta | None = None, functions_acl: list[str] | None = None, has_metrics: bool = True, @@ -144,28 +150,28 @@ def timeseries_query( def top_events_timeseries( - timeseries_columns, - selected_columns, - user_query, - snuba_params, - orderby, - rollup, - limit, - organization, - equations=None, - top_events=None, - allow_empty=True, - zerofill_results=True, - include_other=False, - functions_acl=None, - on_demand_metrics_enabled=False, + timeseries_columns: list[str], + selected_columns: list[str], + user_query: str, + snuba_params: SnubaParams, + orderby: list[str], + rollup: int, + limit: int, + organization: Organization, + equations: list[str] | None = None, + top_events: EventsResponse | None = None, + allow_empty: bool = True, + zerofill_results: bool = True, + include_other: bool = False, + functions_acl: list[str] | None = None, + on_demand_metrics_enabled: bool = False, on_demand_metrics_type: MetricSpecType | None = None, query_source: QuerySource | None = None, fallback_to_transactions: bool = False, transform_alias_to_input_format: bool = False, *, referrer: str, -): +) -> dict[str, SnubaTSResult] | SnubaTSResult: """ High-level API for doing arbitrary user timeseries queries for a limited number of top events diff --git a/src/sentry/utils/managers.py b/src/sentry/utils/managers.py index 676c828100b7..1ffb46fb8ff3 100644 --- a/src/sentry/utils/managers.py +++ b/src/sentry/utils/managers.py @@ -2,31 +2,32 @@ class InstanceManager: - def __init__(self, class_list=None, instances=True): + def __init__(self, class_list: list[str] | None = None, instances: bool = True) -> None: if class_list is None: class_list = [] self.instances = instances + self.cache: list[object] | None = None self.update(class_list) - def get_class_list(self): + def get_class_list(self) -> list[str]: return self.class_list - def add(self, class_path): + def add(self, class_path: str) -> None: self.cache = None self.class_list.append(class_path) - def remove(self, class_path): + def remove(self, class_path: str) -> None: self.cache = None self.class_list.remove(class_path) - def update(self, class_list): + def update(self, class_list: list[str]) -> None: """ Updates the class list and wipes the cache. """ self.cache = None self.class_list = class_list - def all(self): + def all(self) -> list[object]: """ Returns a list of cached instances. """ @@ -38,7 +39,7 @@ def all(self): if self.cache is not None: return self.cache - results = [] + results: list[object] = [] for cls_path in class_list: module_name, class_name = cls_path.rsplit(".", 1) try: diff --git a/src/sentry/web/client_config.py b/src/sentry/web/client_config.py index 3c0d88a42f9b..6f71ba953bbe 100644 --- a/src/sentry/web/client_config.py +++ b/src/sentry/web/client_config.py @@ -8,11 +8,11 @@ from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.contrib.messages import get_messages +from django.contrib.messages.storage.base import Message from django.contrib.sessions.backends.base import SessionBase from django.core.cache import cache from django.http import HttpRequest from packaging.version import parse as parse_version -from rest_framework.request import Request import sentry from sentry import features, options @@ -63,7 +63,7 @@ def _get_support_mail() -> str | None: return options.get("system.support-email") or options.get("system.admin-email") or None -def _get_version_info(): +def _get_version_info() -> dict[str, str | bool]: current = sentry.VERSION latest = options.get("sentry:latest_version") or current @@ -78,7 +78,7 @@ def _get_version_info(): } -def _needs_upgrade(): +def _needs_upgrade() -> bool: version_configured = options.get("sentry:version-configured") if not version_configured: # If we were never previously upgraded (being a new install) @@ -140,7 +140,7 @@ def _get_public_dsn() -> str | None: return result -def _delete_activeorg(session): +def _delete_activeorg(session: SessionBase | None) -> None: if session and "activeorg" in session: del session["activeorg"] @@ -183,7 +183,7 @@ def _resolve_last_org( class _ClientConfig: def __init__( self, - request: Request | None = None, + request: HttpRequest | None = None, org_context: RpcUserOrganizationContext | None = None, ) -> None: self.request = request @@ -239,7 +239,7 @@ def public_dsn(self) -> str | None: return _get_public_dsn() @property - def messages(self): + def messages(self) -> Iterable[Message]: if self.request is None: return [] return get_messages(self.request) @@ -320,7 +320,7 @@ def user_details(self) -> Mapping[str, Any] | None: filter={"user_ids": [self.user.id]}, serializer=UserSerializeType.SELF_DETAILED, auth_context=AuthenticationContext( - auth=self.request.auth if self.request is not None else None, + auth=getattr(self.request, "auth", None) if self.request is not None else None, user=serialize_generic_user(self.user), ), ) @@ -498,7 +498,7 @@ def get_context(self) -> Mapping[str, Any]: def get_client_config( - request=None, org_context: RpcUserOrganizationContext | None = None + request: HttpRequest | None = None, org_context: RpcUserOrganizationContext | None = None ) -> Mapping[str, Any]: """ Provides initial bootstrap data needed to boot the frontend application. diff --git a/src/sentry/web/frontend/oauth_token.py b/src/sentry/web/frontend/oauth_token.py index 3ade49bc3fbc..f6031cfa1950 100644 --- a/src/sentry/web/frontend/oauth_token.py +++ b/src/sentry/web/frontend/oauth_token.py @@ -3,10 +3,10 @@ import base64 import logging from datetime import datetime -from typing import Any, Literal, NotRequired, TypedDict +from typing import Literal, NotRequired, TypedDict from django.db import router, transaction -from django.http import HttpRequest, HttpResponse +from django.http import HttpRequest, HttpResponse, HttpResponseBase from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache @@ -50,10 +50,17 @@ class _TokenInformation(TypedDict): token_type: Literal["Bearer"] scope: str user: _TokenInformationUser - id_token: NotRequired[OpenIDToken] + id_token: NotRequired[str] organization_id: NotRequired[str] +class _TokenGrantResult(TypedDict, total=False): + token: ApiToken + id_token: str + error: str + reason: str + + @control_silo_view class OAuthTokenView(View): # Token responses must not be cached per RFC 6749 §5.1/§5.2. We apply @@ -61,11 +68,17 @@ class OAuthTokenView(View): # appropriately without repeating headers across handlers. @csrf_exempt @method_decorator(never_cache) - def dispatch(self, request, *args, **kwargs): + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponseBase: return super().dispatch(request, *args, **kwargs) # Note: the reason parameter is for internal use only - def error(self, request: HttpRequest, name, reason=None, status=400): + def error( + self, + request: HttpRequest | Request, + name: str, + reason: str | None = None, + status: int = 400, + ) -> HttpResponse: client_id = request.POST.get("client_id") logger.error( @@ -280,11 +293,11 @@ def post(self, request: Request) -> HttpResponse: return self.error( request=request, name=token_data["error"], - reason=token_data["reason"] if "reason" in token_data else None, + reason=token_data.get("reason"), ) return self.process_token_details( token=token_data["token"], - id_token=token_data["id_token"] if "id_token" in token_data else None, + id_token=token_data.get("id_token"), ) def _extract_basic_auth_credentials( @@ -370,7 +383,7 @@ def _extract_basic_auth_credentials( # Neither header nor body provided credentials. return (None, None), None - def get_access_tokens(self, request: Request, application: ApiApplication) -> dict: + def get_access_tokens(self, request: Request, application: ApiApplication) -> _TokenGrantResult: code = request.POST.get("code") try: grant = ApiGrant.objects.get(application=application, code=code) @@ -398,7 +411,7 @@ def get_access_tokens(self, request: Request, application: ApiApplication) -> di "reason": str(e) if str(e) else "grant expired", } - token_data: dict[str, Any] = {"token": api_token} + token_data: _TokenGrantResult = {"token": api_token} # OpenID token generation (stays in endpoint) if grant_has_openid and options.get("codecov.signing_secret"): @@ -420,7 +433,7 @@ def get_access_tokens(self, request: Request, application: ApiApplication) -> di return token_data - def get_refresh_token(self, request: Request, application: ApiApplication) -> dict: + def get_refresh_token(self, request: Request, application: ApiApplication) -> _TokenGrantResult: refresh_token_code = request.POST.get("refresh_token") scope = request.POST.get("scope") @@ -626,9 +639,7 @@ def handle_device_code_grant( reason="device code in invalid state", ) - def process_token_details( - self, token: ApiToken, id_token: OpenIDToken | None = None - ) -> HttpResponse: + def process_token_details(self, token: ApiToken, id_token: str | None = None) -> HttpResponse: token_information: _TokenInformation = { "access_token": token.token, "refresh_token": token.refresh_token, From 5e9e7e7f4ee48dbba3d9611269494c64e94a59f7 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:32:05 -0700 Subject: [PATCH 049/103] ref(typing): integrate lane 12 and nine wildcard families Graduate lane 12's modules and finish the remaining helpers in these wildcard packages. Dynamic API payload and resolution mappings keep justified Any at their existing decode boundaries. --- pyproject.toml | 21 -------------- src/sentry/integrations/messaging/linkage.py | 5 ++-- .../integrations/tasks/sync_status_inbound.py | 10 +++++-- .../endpoints/user_notification_details.py | 20 +++++++++---- ...er_notification_settings_options_detail.py | 9 ++++-- .../models/notificationaction.py | 6 ++-- .../models/notificationsettingbase.py | 19 ++++++++++-- .../shared_integrations/response/mapping.py | 3 +- .../shared_integrations/response/sequence.py | 3 +- .../bitbucket_server/test_integration.py | 29 ++++++++++++++----- .../bitbucket_server/test_repository.py | 3 +- .../sentry/lang/javascript/test_processing.py | 6 ++-- .../test_notification_actions_available.py | 4 +++ tests/sentry/silo/test_client.py | 4 +-- tests/sentry/taskworker/test_config.py | 4 +-- 15 files changed, 92 insertions(+), 54 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6326b87ba96b..2bc3c853a9c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -470,7 +470,6 @@ module = [ "sentry.api.endpoints.organization_traces", "sentry.api.endpoints.organization_unsubscribe", "sentry.api.endpoints.project_artifact_bundle_files", - "sentry.api.endpoints.project_performance_general_settings", "sentry.api.endpoints.project_trace_item_details", "sentry.api.endpoints.project_transaction_threshold", "sentry.api.endpoints.project_transaction_threshold_override", @@ -482,13 +481,11 @@ module = [ "sentry.api.serializers.models.group_stream", "sentry.api.serializers.models.groupseen", "sentry.api.serializers.models.project", - "sentry.api.serializers.models.pullrequest", "sentry.api.serializers.models.release", "sentry.api.serializers.models.userrollback", "sentry.api.serializers.rest_framework.base", "sentry.api.serializers.rest_framework.dashboard", "sentry.api.serializers.snuba", - "sentry.apidocs.hooks", "sentry.apidocs.spectacular_ports", "sentry.assistant.*", "sentry.attachments.*", @@ -502,7 +499,6 @@ module = [ "sentry.db.models.fields.array", "sentry.db.models.fields.bounded", "sentry.db.models.fields.encryption.*", - "sentry.db.models.fields.gzippeddict", "sentry.db.models.fields.jsonfield", "sentry.db.models.fields.node", "sentry.db.models.fields.text", @@ -562,12 +558,10 @@ module = [ "sentry.integrations.jira_server.client", "sentry.integrations.jira_server.integration", "sentry.integrations.jira_server.webhooks", - "sentry.integrations.messaging.*", "sentry.integrations.middleware.*", "sentry.integrations.mixins.*", "sentry.integrations.models.integration", "sentry.integrations.msteams.actions.*", - "sentry.integrations.msteams.client", "sentry.integrations.msteams.utils", "sentry.integrations.msteams.webhook", "sentry.integrations.on_call.*", @@ -575,7 +569,6 @@ module = [ "sentry.integrations.slack.actions.*", "sentry.integrations.slack.integration", "sentry.integrations.slack.message_builder.*", - "sentry.integrations.tasks.*", "sentry.integrations.time_utils", "sentry.integrations.vsts.client", "sentry.interfaces.*", @@ -596,7 +589,6 @@ module = [ "sentry.management.*", "sentry.middleware.devtoolbar", "sentry.models.artifactbundle", - "sentry.models.auditlogentry", "sentry.models.authprovider", "sentry.models.avatars.*", "sentry.models.commit", @@ -618,7 +610,6 @@ module = [ "sentry.models.organizationmemberinvite", "sentry.models.project", "sentry.models.projectcodeowners", - "sentry.models.projectkey", "sentry.models.projectownership", "sentry.models.recentsearch", "sentry.models.release", @@ -639,8 +630,6 @@ module = [ "sentry.net.http", "sentry.new_migrations.*", "sentry.newsletter.*", - "sentry.notifications.api.endpoints.*", - "sentry.notifications.models.*", "sentry.onboarding_tasks.*", "sentry.options.manager", "sentry.options.store", @@ -649,7 +638,6 @@ module = [ "sentry.plugins.providers.base", "sentry.projectoptions.*", "sentry.receivers.*", - "sentry.relay.datascrubbing", "sentry.relay.projectconfig_cache.*", "sentry.relay.projectconfig_debounce_cache.*", "sentry.relay.utils", @@ -686,7 +674,6 @@ module = [ "sentry.services.filestore.*", "sentry.services.http", "sentry.services.organization.*", - "sentry.shared_integrations.response.*", "sentry.similarity.*", "sentry.snuba.functions", "sentry.snuba.metrics.fields.*", @@ -695,7 +682,6 @@ module = [ "sentry.snuba.metrics_performance", "sentry.snuba.outcomes", "sentry.snuba.sessions_v2", - "sentry.snuba.spans_metrics", "sentry.spans.consumers.process.*", "sentry.stacktraces.functions", "sentry.stacktraces.processing", @@ -746,7 +732,6 @@ module = [ "sentry.utils.cursored_scheduler", "sentry.utils.datastructures", "sentry.utils.db", - "sentry.utils.managers", "sentry.utils.marketo_client", "sentry.utils.meta", "sentry.utils.mockdata.*", @@ -759,13 +744,11 @@ module = [ "sentry.utils.types", "sentry.utils.validators", "sentry.web.api", - "sentry.web.client_config", "sentry.web.forms.*", "sentry.web.frontend.auth_login", "sentry.web.frontend.disabled_member_view", "sentry.web.frontend.generic", "sentry.web.frontend.mailgun_inbound_webhook", - "sentry.web.frontend.oauth_token", "sentry.web.frontend.react_page", "sentry.web.frontend.release_webhook", "sentry.web.frontend.setup_wizard", @@ -983,7 +966,6 @@ module = [ "tests.sentry.integrations.api.bases.*", "tests.sentry.integrations.api.endpoints.*", "tests.sentry.integrations.bitbucket.*", - "tests.sentry.integrations.bitbucket_server.*", "tests.sentry.integrations.claude_code.*", "tests.sentry.integrations.cursor.*", "tests.sentry.integrations.data_forwarding.*", @@ -1039,7 +1021,6 @@ module = [ "tests.sentry.issues.test_issue_search", "tests.sentry.issues.test_utils", "tests.sentry.lang", - "tests.sentry.lang.javascript.*", "tests.sentry.lang.native.*", "tests.sentry.logging.*", "tests.sentry.mail", @@ -1213,7 +1194,6 @@ module = [ "tests.sentry.services.test_http", "tests.sentry.services.test_organization_actions", "tests.sentry.shared_integrations.*", - "tests.sentry.silo.*", "tests.sentry.similarity", "tests.sentry.similarity.test_encoder", "tests.sentry.similarity.test_signatures", @@ -1295,7 +1275,6 @@ module = [ "tests.sentry.tasks.test_web_vitals_issue_detection", "tests.sentry.tasks.test_weekly_escalating_forecast", "tests.sentry.tasks.test_weekly_reports", - "tests.sentry.taskworker.*", "tests.sentry.test_constants", "tests.sentry.test_culprit", "tests.sentry.test_dependencies", diff --git a/src/sentry/integrations/messaging/linkage.py b/src/sentry/integrations/messaging/linkage.py index 7a0008e2d598..61c31d7153f0 100644 --- a/src/sentry/integrations/messaging/linkage.py +++ b/src/sentry/integrations/messaging/linkage.py @@ -176,7 +176,8 @@ def dispatch(self, request: HttpRequest, signed_params: str) -> HttpResponseBase dispatch_kwargs = {k: v for (k, v) in dispatch_kwargs.items() if v is not None} return super().dispatch(request, **dispatch_kwargs) - def get(self, request: Request, *args, **kwargs) -> HttpResponse: + # justification: Django view kwargs carry the pipeline params mapping. + def get(self, request: Request, *args: object, **kwargs: Any) -> HttpResponse: params = kwargs["params"] context = {"organization": params["organization"]} integration = params.get("integration") @@ -220,7 +221,7 @@ def post(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse: ) return render_to_response(success_template, request=request, context=success_context) - def _send_nudge_notification(self, organization: RpcOrganization, request: Request): + def _send_nudge_notification(self, organization: RpcOrganization, request: Request) -> None: # TODO: Delete this if no longer needed user: User = request.user # type: ignore[assignment] diff --git a/src/sentry/integrations/tasks/sync_status_inbound.py b/src/sentry/integrations/tasks/sync_status_inbound.py index be204d31c28b..0573e2ea9166 100644 --- a/src/sentry/integrations/tasks/sync_status_inbound.py +++ b/src/sentry/integrations/tasks/sync_status_inbound.py @@ -37,14 +37,18 @@ logger = logging.getLogger(__name__) +# justification: resolution kwargs vary by strategy and feed Django's update(). +ResolutionParams = dict[str, Any] + + def get_resolutions_and_activity_data_for_groups( affected_groups: Iterable[Group], resolution_strategy: str | None, - activity_data: dict, + activity_data: dict[str, object], organization_id: int, -): +) -> tuple[dict[int, ResolutionParams], ActivityType, dict[str, object]]: activity_data = activity_data.copy() - resolutions_by_group_id = {} + resolutions_by_group_id: dict[int, ResolutionParams] = {} activity_type = ActivityType.SET_RESOLVED # If the resolution strategy is set to resolve in the next release or current release if resolution_strategy in [ diff --git a/src/sentry/notifications/api/endpoints/user_notification_details.py b/src/sentry/notifications/api/endpoints/user_notification_details.py index bfaf6f6fca8a..eada4f734c20 100644 --- a/src/sentry/notifications/api/endpoints/user_notification_details.py +++ b/src/sentry/notifications/api/endpoints/user_notification_details.py @@ -1,5 +1,6 @@ from collections import defaultdict -from collections.abc import Sequence +from collections.abc import Mapping, Sequence +from typing import cast from django.contrib.auth.models import AnonymousUser from rest_framework import serializers, status @@ -28,7 +29,7 @@ } -class UserNotificationsSerializer(Serializer): +class UserNotificationsSerializer(Serializer[dict[str, bool]]): def get_attrs( self, item_list: Sequence[User], @@ -46,13 +47,22 @@ def get_attrs( results[user_option.user].append(user_option) return results - def serialize(self, obj, attrs, user, *args, **kwargs): - raw_data = {option.key: option.value for option in attrs} + def serialize( + self, + obj: User, + attrs: Mapping[object, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[str, bool]: + # justification: the serializer pipeline passes this object's get_attrs list. + options = cast(Sequence[UserOption], attrs) + raw_data = {option.key: option.value for option in options} data = {} for key, uo in USER_OPTION_SETTINGS.items(): val = raw_data.get(uo["key"], uo["default"]) - data[key.value] = bool(int(val)) # '1' is true, '0' is false + # justification: stored option values are numeric strings. + data[key.value] = bool(int(cast(str, val))) # '1' is true, '0' is false return data diff --git a/src/sentry/notifications/api/endpoints/user_notification_settings_options_detail.py b/src/sentry/notifications/api/endpoints/user_notification_settings_options_detail.py index 2f7aec3c1825..9410e4d5ddc4 100644 --- a/src/sentry/notifications/api/endpoints/user_notification_settings_options_detail.py +++ b/src/sentry/notifications/api/endpoints/user_notification_settings_options_detail.py @@ -1,3 +1,5 @@ +from typing import cast + from rest_framework import status from rest_framework.exceptions import NotFound from rest_framework.request import Request @@ -23,12 +25,13 @@ def convert_args( self, request: Request, user_id: int | str | None = None, - *args, + *args: object, notification_option_id: int, - **kwargs, + **kwargs: object, ) -> tuple[tuple[object, ...], dict[str, object]]: args, kwargs = super().convert_args(request, user_id, *args, **kwargs) - user = kwargs["user"] + # justification: convert_args places the resolved user in kwargs. + user = cast(User, kwargs["user"]) try: option = NotificationSettingOption.objects.get( id=to_valid_int_id( diff --git a/src/sentry/notifications/models/notificationaction.py b/src/sentry/notifications/models/notificationaction.py index 939d28020268..49ec58e9c128 100644 --- a/src/sentry/notifications/models/notificationaction.py +++ b/src/sentry/notifications/models/notificationaction.py @@ -2,7 +2,7 @@ import logging from abc import ABCMeta, abstractmethod -from collections.abc import Mapping, MutableMapping +from collections.abc import Callable, Mapping, MutableMapping from enum import IntEnum from typing import TYPE_CHECKING, Any, TypeVar @@ -227,7 +227,9 @@ class Meta: db_table = "sentry_notificationaction" @classmethod - def register_action(cls, trigger_type: int, service_type: int, target_type: int): + def register_action( + cls, trigger_type: int, service_type: int, target_type: int + ) -> Callable[[type[ActionRegistrationT]], type[ActionRegistrationT]]: """ Register a new trigger/service/target combination for NotificationActions. For example, allowing audit-logs (trigger) to fire actions to slack (service) channels (target) diff --git a/src/sentry/notifications/models/notificationsettingbase.py b/src/sentry/notifications/models/notificationsettingbase.py index d0d6a57b5d83..ef81696b2a86 100644 --- a/src/sentry/notifications/models/notificationsettingbase.py +++ b/src/sentry/notifications/models/notificationsettingbase.py @@ -1,6 +1,9 @@ +from collections.abc import Iterable + import sentry_sdk from django.conf import settings from django.db import models +from django.db.models.base import ModelBase from sentry.backup.scopes import RelocationScope from sentry.db.models import BoundedBigIntegerField, DefaultFieldsModelExisting, FlexibleForeignKey @@ -22,11 +25,23 @@ class NotificationSettingBase(DefaultFieldsModelExisting): class Meta: abstract = True - def save(self, *args, **kwargs): + def save( + self, + *, + force_insert: bool | tuple[ModelBase, ...] = False, + force_update: bool = False, + using: str | None = None, + update_fields: Iterable[str] | None = None, + ) -> None: try: assert not (self.user_id is None and self.team_id is None), ( "Notification setting missing user & team" ) except AssertionError as err: sentry_sdk.capture_exception(err) - super().save(*args, **kwargs) + super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) diff --git a/src/sentry/shared_integrations/response/mapping.py b/src/sentry/shared_integrations/response/mapping.py index 3e194643e01f..149de7d058b9 100644 --- a/src/sentry/shared_integrations/response/mapping.py +++ b/src/sentry/shared_integrations/response/mapping.py @@ -6,7 +6,8 @@ from sentry.shared_integrations.response.base import BaseApiResponse -class MappingApiResponse(dict, BaseApiResponse): +# justification: API response payloads are decoded JSON of mixed types. +class MappingApiResponse(dict[str, Any], BaseApiResponse): def __init__(self, data: Mapping[str, Any], *args: Any, **kwargs: Any) -> None: dict.__init__(self, data) BaseApiResponse.__init__(self, *args, **kwargs) diff --git a/src/sentry/shared_integrations/response/sequence.py b/src/sentry/shared_integrations/response/sequence.py index fff6f4db7b9d..576370988e43 100644 --- a/src/sentry/shared_integrations/response/sequence.py +++ b/src/sentry/shared_integrations/response/sequence.py @@ -6,7 +6,8 @@ from sentry.shared_integrations.response.base import BaseApiResponse -class SequenceApiResponse(list, BaseApiResponse): +# justification: API response payloads are decoded JSON of mixed types. +class SequenceApiResponse(list[Any], BaseApiResponse): def __init__(self, data: Sequence[Any], *args: Any, **kwargs: Any) -> None: list.__init__(self, data) BaseApiResponse.__init__(self, *args, **kwargs) diff --git a/tests/sentry/integrations/bitbucket_server/test_integration.py b/tests/sentry/integrations/bitbucket_server/test_integration.py index 141415789586..e7add7ce8b42 100644 --- a/tests/sentry/integrations/bitbucket_server/test_integration.py +++ b/tests/sentry/integrations/bitbucket_server/test_integration.py @@ -1,5 +1,5 @@ from functools import cached_property -from typing import Any +from typing import Any, cast from unittest.mock import MagicMock, patch import responses @@ -7,7 +7,10 @@ from requests.exceptions import ReadTimeout from fixtures.bitbucket_server import EXAMPLE_PRIVATE_KEY -from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegrationProvider +from sentry.integrations.bitbucket_server.integration import ( + BitbucketServerIntegration, + BitbucketServerIntegrationProvider, +) from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.pipeline import IntegrationPipeline @@ -25,7 +28,7 @@ class BitbucketServerIntegrationTest(IntegrationTestCase): @cached_property @assume_test_silo_mode(SiloMode.CONTROL) - def integration(self): + def integration(self) -> Integration: integration = Integration.objects.create( provider=self.provider.key, name="Bitbucket Server", @@ -39,7 +42,10 @@ def integration(self): return integration def test_source_url_matches(self) -> None: - installation = self.integration.get_installation(self.organization.id) + installation = cast( + BitbucketServerIntegration, + self.integration.get_installation(self.organization.id), + ) test_cases = [ ( @@ -68,7 +74,10 @@ def test_source_url_matches(self) -> None: assert installation.source_url_matches(source_url) == matches def test_format_source_url(self) -> None: - installation = self.integration.get_installation(self.organization.id) + installation = cast( + BitbucketServerIntegration, + self.integration.get_installation(self.organization.id), + ) with assume_test_silo_mode(SiloMode.CELL): repo = Repository.objects.create( @@ -95,7 +104,10 @@ def test_format_source_url(self) -> None: ) def test_extract_branch_from_source_url(self) -> None: - installation = self.integration.get_installation(self.organization.id) + installation = cast( + BitbucketServerIntegration, + self.integration.get_installation(self.organization.id), + ) with assume_test_silo_mode(SiloMode.CELL): repo = Repository.objects.create( @@ -126,7 +138,10 @@ def test_extract_branch_from_source_url(self) -> None: ) def test_extract_source_path_from_source_url(self) -> None: - installation = self.integration.get_installation(self.organization.id) + installation = cast( + BitbucketServerIntegration, + self.integration.get_installation(self.organization.id), + ) with assume_test_silo_mode(SiloMode.CELL): repo = Repository.objects.create( diff --git a/tests/sentry/integrations/bitbucket_server/test_repository.py b/tests/sentry/integrations/bitbucket_server/test_repository.py index 3138128d63b3..8376fce450bf 100644 --- a/tests/sentry/integrations/bitbucket_server/test_repository.py +++ b/tests/sentry/integrations/bitbucket_server/test_repository.py @@ -5,6 +5,7 @@ import orjson import pytest import responses +from requests import PreparedRequest from fixtures.bitbucket_server import ( COMMIT_CHANGELIST_EXAMPLE, @@ -298,7 +299,7 @@ def _on_create_repository_discards_hook_when_repository_disabled( integration_id=self.integration.id, ) - def create(request): + def create(request: PreparedRequest) -> tuple[int, dict[str, str], str]: Repository.objects.filter(id=repo.id).update(status=ObjectStatus.DISABLED) return 201, {}, '{"id": 79}' diff --git a/tests/sentry/lang/javascript/test_processing.py b/tests/sentry/lang/javascript/test_processing.py index bb8e24742e73..ba959c391066 100644 --- a/tests/sentry/lang/javascript/test_processing.py +++ b/tests/sentry/lang/javascript/test_processing.py @@ -122,7 +122,9 @@ def test_node_modules_regex(self) -> None: self.assertIsNone(NODE_MODULES_RE.search("node_modules")) self.assertIsNone(NODE_MODULES_RE.search("mynode_modules")) - def _get_test_data_and_symbolicator(self, in_app_frames: bool, symbolicated_in_app=True): + def _get_test_data_and_symbolicator( + self, in_app_frames: bool, symbolicated_in_app: bool = True + ) -> tuple[dict[str, object], Mock]: """Helper method to create test data and mock symbolicator Args: @@ -221,7 +223,7 @@ def _get_test_data_and_symbolicator(self, in_app_frames: bool, symbolicated_in_a }, ] - data = { + data: dict[str, object] = { "platform": "javascript", "exception": { "values": [ diff --git a/tests/sentry/notifications/api/endpoints/test_notification_actions_available.py b/tests/sentry/notifications/api/endpoints/test_notification_actions_available.py index ffd70b0f2e8f..ba91c4949b89 100644 --- a/tests/sentry/notifications/api/endpoints/test_notification_actions_available.py +++ b/tests/sentry/notifications/api/endpoints/test_notification_actions_available.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import MagicMock, patch from rest_framework import status @@ -44,6 +45,9 @@ def test_get_dynamic_response(self, mock_action_trigger: MagicMock) -> None: class MockActionRegistration(ActionRegistration): serialize_available = MagicMock(return_value=[trigger_available_response]) + def fire(self, data: Any) -> None: + pass + response = self.get_success_response( self.organization.slug, status_code=status.HTTP_200_OK, diff --git a/tests/sentry/silo/test_client.py b/tests/sentry/silo/test_client.py index b350bade6b70..e2cb926dc4e5 100644 --- a/tests/sentry/silo/test_client.py +++ b/tests/sentry/silo/test_client.py @@ -402,7 +402,7 @@ def test_client_restricted_ip_address(self) -> None: class BailOut(Exception): pass - def test_validate_cell_ip_address(ip) -> None: + def test_validate_cell_ip_address(ip: str) -> None: assert ip == "172.31.255.255" # We can't use responses library for this unit test as it hooks Session.send. So we assert that the # validate_cell_ip_address function is properly called for the proxy request code path. @@ -484,7 +484,7 @@ def test_get_cell_ip_addresses_when_single_host_invalid(mock_incr: MagicMock) -> cell_config = (us1_cell, us2_cell, dead_cell) - def mock_gethostbyname_for_cells(hostname): + def mock_gethostbyname_for_cells(hostname: str) -> str: if hostname == us1_cell_address.split("//")[1].split(":")[0]: return "172.31.10.1" elif hostname == us2_cell_address.split("//")[1].split(":")[0]: diff --git a/tests/sentry/taskworker/test_config.py b/tests/sentry/taskworker/test_config.py index d786e4bea645..508dcbe28d12 100644 --- a/tests/sentry/taskworker/test_config.py +++ b/tests/sentry/taskworker/test_config.py @@ -41,7 +41,7 @@ def test_taskworker_schedule_unique() -> None: @pytest.mark.parametrize("name,config", list(settings.TASKWORKER_SCHEDULES.items())) -def test_taskworker_schedule_type(name: str, config: dict[str, Any], load_tasks) -> None: +def test_taskworker_schedule_type(name: str, config: dict[str, Any], load_tasks: object) -> None: assert config["task"], f"schedule {name} is missing a task name" (namespace, taskname) = config["task"].split(":") assert app.taskregistry.get_task(namespace, taskname), f"task for {name} is not registered" @@ -53,7 +53,7 @@ def test_taskworker_schedule_type(name: str, config: dict[str, Any], load_tasks) ) -def test_taskworker_schedule_parameters(load_tasks) -> None: +def test_taskworker_schedule_parameters(load_tasks: object) -> None: for config in settings.TASKWORKER_SCHEDULES.values(): (namespace, taskname) = config["task"].split(":") task = app.taskregistry.get_task(namespace, taskname) From 650c378e4f833f536ad16b457aedb297c9feddd5 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:33:22 -0700 Subject: [PATCH 050/103] ref(typing): graduate lane 10 weaklist modules Annotate the lane's modules so their disallow_untyped_defs weaklist entries can be dropped. Two modules remain weaklisted: the stats summary endpoint needs a guard for a nullable CSV body, and sentry.utils.safe's get_path returns arbitrary JSON, which callers read as typed values. --- src/sentry/api/authentication.py | 2 +- .../project_transaction_threshold.py | 13 +- src/sentry/api/serializers/models/release.py | 112 ++++++++++++------ src/sentry/apidocs/spectacular_ports.py | 20 ++-- src/sentry/discover/dataset_split.py | 14 ++- src/sentry/integrations/msteams/webhook.py | 12 +- src/sentry/models/authprovider.py | 16 ++- src/sentry/models/projectownership.py | 14 +-- src/sentry/relay/utils.py | 28 ++++- src/sentry/tasks/files.py | 31 ++++- src/sentry/types/actor.py | 16 ++- src/sentry/web/frontend/auth_login.py | 17 ++- src/sentry/web/frontend/release_webhook.py | 13 +- 13 files changed, 208 insertions(+), 100 deletions(-) diff --git a/src/sentry/api/authentication.py b/src/sentry/api/authentication.py index 4f6919e2ca7a..47764cda299e 100644 --- a/src/sentry/api/authentication.py +++ b/src/sentry/api/authentication.py @@ -136,7 +136,7 @@ def is_static_relay(request): """ relay_id = get_header_relay_id(request) static_relays = settings.SENTRY_RELAY_STATIC_AUTH - relay_info = static_relays.get(relay_id) + relay_info = static_relays.get(relay_id) if relay_id is not None else None if relay_info is not None: metrics.incr( diff --git a/src/sentry/api/endpoints/project_transaction_threshold.py b/src/sentry/api/endpoints/project_transaction_threshold.py index 550b99c2cd51..faabce9d0c5b 100644 --- a/src/sentry/api/endpoints/project_transaction_threshold.py +++ b/src/sentry/api/endpoints/project_transaction_threshold.py @@ -8,6 +8,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint, ProjectSettingPermission from sentry.api.serializers import serialize +from sentry.models.project import Project from sentry.models.transaction_threshold import ( TRANSACTION_METRICS, ProjectTransactionThreshold, @@ -26,14 +27,14 @@ class ProjectTransactionThresholdSerializer(serializers.Serializer): threshold = serializers.IntegerField(required=False, max_value=MAX_VALUE) metric = serializers.CharField(required=False) - def validate_metric(self, metric): + def validate_metric(self, metric: str) -> int: for key, value in TRANSACTION_METRICS.items(): if value == metric: return key raise serializers.ValidationError(f"Invalid transaction metric - {metric}") - def validate_threshold(self, threshold): + def validate_threshold(self, threshold: int) -> int: if threshold % 100: raise serializers.ValidationError("Invalid threshold - specify a multiple of 100") @@ -56,12 +57,12 @@ class ProjectTransactionThresholdEndpoint(ProjectEndpoint): } permission_classes = (ProjectSettingPermission,) - def has_feature(self, project, request): + def has_feature(self, project: Project, request: Request) -> bool: return features.has( "organizations:performance-view", project.organization, actor=request.user ) - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project: Project) -> Response: if not self.has_feature(project, request): return self.respond(status=status.HTTP_404_NOT_FOUND) @@ -84,7 +85,7 @@ def get(self, request: Request, project) -> Response: status.HTTP_200_OK, ) - def post(self, request: Request, project) -> Response: + def post(self, request: Request, project: Project) -> Response: if not self.has_feature(project, request): return self.respond(status=status.HTTP_404_NOT_FOUND) @@ -120,7 +121,7 @@ def post(self, request: Request, project) -> Response: status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, ) - def delete(self, request: Request, project) -> Response: + def delete(self, request: Request, project: Project) -> Response: if not self.has_feature(project, request): return self.respond(status=status.HTTP_404_NOT_FOUND) diff --git a/src/sentry/api/serializers/models/release.py b/src/sentry/api/serializers/models/release.py index 2ac584b8a29d..24cdd792b992 100644 --- a/src/sentry/api/serializers/models/release.py +++ b/src/sentry/api/serializers/models/release.py @@ -3,7 +3,7 @@ import datetime from collections import defaultdict from collections.abc import Mapping, Sequence -from typing import NotRequired, TypedDict +from typing import Literal, NotRequired, TypedDict, cast from django.contrib.auth.models import AnonymousUser from django.core.cache import cache @@ -14,6 +14,7 @@ from sentry.api.serializers.release_details_types import ( Author, HealthData, + LastDeploy, NonMappableUser, VersionInfo, ) @@ -26,12 +27,13 @@ from sentry.models.commit import Commit from sentry.models.commitauthor import CommitAuthor from sentry.models.deploy import Deploy +from sentry.models.environment import Environment from sentry.models.project import Project from sentry.models.projectplatform import ProjectPlatform from sentry.models.release import Release, ReleaseStatus from sentry.models.releaseprojectenvironment import AdoptionStage, ReleaseProjectEnvironment from sentry.models.releases.release_project import ReleaseProject -from sentry.release_health.base import ReleaseHealthOverview +from sentry.release_health.base import ReleaseHealthOverview, StatsPeriod from sentry.users.api.serializers.user import UserSerializerResponse from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser @@ -41,10 +43,27 @@ from sentry.utils.hashlib import md5_text -def expose_version_info(info) -> VersionInfo | None: +class _ParsedVersion(TypedDict): + major: int + minor: int + patch: int + pre: str | None + build_code: str | None + components: int + + +class _RawVersionInfo(TypedDict): + version_raw: str + version_parsed: _ParsedVersion | None + package: str | None + description: str + build_hash: str | None + + +def expose_version_info(info: _RawVersionInfo | None) -> VersionInfo | None: if info is None: return None - version = {"raw": info["version_raw"]} + version: dict[str, object] = {"raw": info["version_raw"]} if info["version_parsed"]: version.update( { @@ -418,6 +437,18 @@ def get_users_for_authors( return final_results +class _ReleaseAttrs(TypedDict): + owner: dict[str, object] | None + new_groups: int + projects: list[_ProjectDict] + first_seen: datetime.datetime | None + last_seen: datetime.datetime | None + adoption_stages: NotRequired[dict[str, AdoptionStage] | None] + authors: NotRequired[list[Author]] + last_commit: NotRequired[dict[str, object] | None] + last_deploy: NotRequired[LastDeploy | None] + + class _ProjectDict(TypedDict): id: int slug: str @@ -450,7 +481,14 @@ def __get_project_id_list(self, item_list: Sequence[Release]) -> list[int]: .distinct() ) - def __get_release_data_no_environment(self, project, item_list, no_snuba_for_release_creation): + def __get_release_data_no_environment( + self, + project: Project | None, + item_list: Sequence[Release], + no_snuba_for_release_creation: bool, + ) -> tuple[ + dict[str, datetime.datetime], dict[str, datetime.datetime], dict[int, dict[int, int]] + ]: if project is not None: project_ids = [project.id] organization_id = project.organization_id @@ -476,17 +514,19 @@ def __get_release_data_no_environment(self, project, item_list, no_snuba_for_rel first_seen[tv.value] = min(tv.first_seen, first_val) if first_val else tv.first_seen last_seen[tv.value] = max(tv.last_seen, last_val) if last_val else tv.last_seen - group_counts_by_release = {} + group_counts_by_release: dict[int, dict[int, int]] = {} if project is not None: for release_id, new_groups in ReleaseProject.objects.filter( project=project, release__in=item_list, new_groups__isnull=False ).values_list("release_id", "new_groups"): - group_counts_by_release[release_id] = {project.id: new_groups} + group_counts_by_release[release_id] = {project.id: cast(int, new_groups)} else: for project_id, release_id, new_groups in ReleaseProject.objects.filter( release__in=item_list, new_groups__isnull=False ).values_list("project_id", "release_id", "new_groups"): - group_counts_by_release.setdefault(release_id, {})[project_id] = new_groups + group_counts_by_release.setdefault(release_id, {})[project_id] = cast( + int, new_groups + ) return first_seen, last_seen, group_counts_by_release @@ -567,25 +607,30 @@ def _get_release_project_envs_unordered( return release_project_envs - def get_attrs(self, item_list, user, **kwargs): - project = kwargs.get("project") + def get_attrs( + self, + item_list: Sequence[Release], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> dict[Release, dict[str, object]]: + project = cast(Project | None, kwargs.get("project")) # Some code paths pass an environment object, other pass a list of # environment names. - environment = kwargs.get("environment") - environments = kwargs.get("environments") + environment = cast(Environment | None, kwargs.get("environment")) + environments = cast(Sequence[str] | None, kwargs.get("environments")) if not environments: if environment: environments = [environment.name] else: environments = None - self.with_adoption_stages = kwargs.get("with_adoption_stages", False) - with_health_data = kwargs.get("with_health_data", False) - health_stat = kwargs.get("health_stat", None) - health_stats_period = kwargs.get("health_stats_period") - summary_stats_period = kwargs.get("summary_stats_period") - no_snuba_for_release_creation = kwargs.get("no_snuba_for_release_creation") + self.with_adoption_stages = cast(bool, kwargs.get("with_adoption_stages", False)) + with_health_data = cast(bool, kwargs.get("with_health_data", False)) + health_stat = cast(Literal["users", "sessions"] | None, kwargs.get("health_stat", None)) + health_stats_period = cast(StatsPeriod | None, kwargs.get("health_stats_period")) + summary_stats_period = cast(StatsPeriod | None, kwargs.get("summary_stats_period")) + no_snuba_for_release_creation = cast(bool, kwargs.get("no_snuba_for_release_creation")) if with_health_data and no_snuba_for_release_creation: raise TypeError("health data requires snuba") @@ -724,10 +769,11 @@ def get_attrs(self, item_list, user, **kwargs): def serialize( self, obj: Release, - attrs, + attrs: Mapping[str, object], user: User | RpcUser | AnonymousUser, - **kwargs, + **kwargs: object, ) -> ReleaseSerializerResponse: + typed_attrs = cast(_ReleaseAttrs, attrs) d: ReleaseSerializerResponse = { "id": obj.id, "version": obj.version, @@ -739,25 +785,25 @@ def serialize( "dateReleased": obj.date_released, "dateCreated": obj.date_added, "data": obj.data, - "newGroups": attrs["new_groups"], - "owner": attrs["owner"], + "newGroups": typed_attrs["new_groups"], + "owner": typed_attrs["owner"], "commitCount": obj.commit_count, - "lastCommit": attrs.get("last_commit"), + "lastCommit": typed_attrs.get("last_commit"), "deployCount": obj.total_deploys, - "lastDeploy": attrs.get("last_deploy"), - "authors": attrs.get("authors", []), - "projects": [_expose_project(p) for p in attrs.get("projects", [])], - "firstEvent": attrs.get("first_seen"), - "lastEvent": attrs.get("last_seen"), + "lastDeploy": typed_attrs.get("last_deploy"), + "authors": typed_attrs.get("authors", []), + "projects": [_expose_project(p) for p in typed_attrs["projects"]], + "firstEvent": typed_attrs["first_seen"], + "lastEvent": typed_attrs["last_seen"], "currentProjectMeta": _expose_current_project_meta( - kwargs.get("current_project_meta", {}) + cast(dict[str, object], kwargs.get("current_project_meta", {})) ), "userAgent": obj.user_agent, } if self.with_adoption_stages: d.update( { - "adoptionStages": attrs.get("adoption_stages"), + "adoptionStages": typed_attrs.get("adoption_stages"), } ) return d @@ -789,7 +835,7 @@ def get_attrs( def serialize( self, obj: Release, - attrs, + attrs: Mapping[str, object], user: User | RpcUser | AnonymousUser, **kwargs: object, ) -> GroupEventReleaseSerializerResponse: @@ -801,8 +847,8 @@ def serialize( "dateReleased": obj.date_released, "deployCount": obj.total_deploys, "ref": obj.ref, - "lastCommit": attrs.get("last_commit"), - "lastDeploy": attrs.get("last_deploy"), + "lastCommit": cast(dict[str, object] | None, attrs.get("last_commit")), + "lastDeploy": cast(LastDeploy | None, attrs.get("last_deploy")), "status": ReleaseStatus.to_string(obj.status), "url": obj.url, "userAgent": obj.user_agent, diff --git a/src/sentry/apidocs/spectacular_ports.py b/src/sentry/apidocs/spectacular_ports.py index 7e70f12d20f7..f9d44ed3464b 100644 --- a/src/sentry/apidocs/spectacular_ports.py +++ b/src/sentry/apidocs/spectacular_ports.py @@ -32,6 +32,7 @@ import inspect import typing from collections import defaultdict +from collections.abc import Sequence from enum import Enum from types import UnionType from typing import Any, Literal, Union, is_typeddict @@ -66,13 +67,18 @@ # map TypedDicts in schema registry -def get_type_hints(hint, **kwargs): +def get_type_hints( + hint: Any, + globalns: dict[str, Any] | None = None, + localns: dict[str, Any] | None = None, + include_extras: bool = False, +) -> dict[str, Any]: try: - return _get_type_hints(hint, **kwargs) + return _get_type_hints(hint, globalns, localns, include_extras) except NameError: # try to resolve a circular import from TYPE_CHECKING imports reload_module_with_type_checking_enabled(hint.__module__) - return _get_type_hints(hint, **kwargs) + return _get_type_hints(hint, globalns, localns, include_extras) except TypeError: raise UnableToProceedError( f"""Unable to resolve type hints for {hint}. @@ -82,11 +88,11 @@ def get_type_hints(hint, **kwargs): ) -def _get_type_hint_origin(hint): +def _get_type_hint_origin(hint: Any) -> tuple[Any, tuple[Any, ...]]: return typing.get_origin(hint), typing.get_args(hint) -def build_choice_description_list(choices) -> str: +def build_choice_description_list(choices: Sequence[tuple[object, object]]) -> str: """ Override the default generated description for choicefields if the value and label are identical to be (* `value`) instead of (* `value` - `label`). @@ -102,7 +108,7 @@ def build_choice_description_list(choices) -> str: def build_array_type( schema: _SchemaType | None, min_length: int | None = None, max_length: int | None = None -): +) -> _SchemaType: """ This function wraps the build_array_type from `drf-spectacular` because it is not type-safe. """ @@ -124,7 +130,7 @@ def _cycle_placeholder(hint: Any) -> dict[str, Any]: } -def resolve_type_hint(hint) -> Any: +def resolve_type_hint(hint: Any) -> Any: """drf-spectacular library method modified as described above""" origin, args = _get_type_hint_origin(hint) excluded_fields = get_override(hint, "exclude_fields", []) diff --git a/src/sentry/discover/dataset_split.py b/src/sentry/discover/dataset_split.py index d79b21060946..7f8f91ac06c8 100644 --- a/src/sentry/discover/dataset_split.py +++ b/src/sentry/discover/dataset_split.py @@ -120,7 +120,7 @@ def _save_split_decision_for_query( saved_query: DiscoverSavedQuery, split_decision: int | None, dataset_source: DatasetSourcesTypes | None, -): +) -> None: if split_decision is not None: saved_query.dataset = split_decision if dataset_source is not None: @@ -129,7 +129,9 @@ def _save_split_decision_for_query( saved_query.save() -def _get_top_level_filter_conditions(builder: ErrorsQueryBuilder | DiscoverQueryBuilder): +def _get_top_level_filter_conditions( + builder: ErrorsQueryBuilder | DiscoverQueryBuilder, +) -> list[Condition | BooleanCondition]: top_level_conditions: list[Condition | BooleanCondition] = [] for cond in builder.where: if isinstance(cond, And) or isinstance(cond, Or): @@ -195,7 +197,7 @@ def _check_column_matches_dataset( return False -def _check_event_type_condition(cond: Condition, dataset: Dataset): +def _check_event_type_condition(cond: Condition, dataset: Dataset) -> bool: op = cond.op rhs = cond.rhs if dataset == Dataset.Events and ( @@ -226,7 +228,7 @@ def _check_condition_matches_dataset( def _check_top_level_conditions_match_dataset( builder: ErrorsQueryBuilder | DiscoverQueryBuilder, dataset: Dataset, -): +) -> bool: top_level_conditions = _get_top_level_filter_conditions(builder) for cond in top_level_conditions: if isinstance(cond, Condition): @@ -239,7 +241,7 @@ def _check_top_level_conditions_match_dataset( def _check_selected_columns_match_dataset( builder: ErrorsQueryBuilder | DiscoverQueryBuilder, dataset: Dataset, -): +) -> bool: for select_col in builder.columns: if isinstance(select_col, Column): if _check_column_matches_dataset(select_col, dataset): @@ -259,7 +261,7 @@ def _check_selected_columns_match_dataset( return False -def _check_event_type_filter(errors_builder) -> SplitDataset | None: +def _check_event_type_filter(errors_builder: ErrorsQueryBuilder) -> SplitDataset | None: top_level_conditions = _get_top_level_filter_conditions(errors_builder) for cond in top_level_conditions: diff --git a/src/sentry/integrations/msteams/webhook.py b/src/sentry/integrations/msteams/webhook.py index ec6a9c885346..3adfa672d615 100644 --- a/src/sentry/integrations/msteams/webhook.py +++ b/src/sentry/integrations/msteams/webhook.py @@ -123,7 +123,7 @@ class MsTeamsIntegrationUnassign(MsTeamsIntegrationAnalytics): analytics.register(MsTeamsIntegrationUnassign) -def verify_signature(request) -> bool: +def verify_signature(request: Request) -> bool: # docs for jwt authentication here: https://docs.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0#bot-to-connector token = request.META.get("HTTP_AUTHORIZATION", "").replace("Bearer ", "") if not token: @@ -220,7 +220,7 @@ class MsTeamsWebhookEndpoint(Endpoint): permission_classes = () provider = IntegrationProviderSlug.MSTEAMS.value - def __init__(self, **kwargs) -> None: + def __init__(self, **kwargs: object) -> None: super().__init__(**kwargs) self._event_handlers: dict[MsTeamsEvents, Callable[[Request], Response]] = { MsTeamsEvents.MESSAGE: self._handle_message_event, @@ -230,7 +230,7 @@ def __init__(self, **kwargs) -> None: } @csrf_exempt - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: return super().dispatch(request, *args, **kwargs) def post(self, request: Request) -> Response: @@ -368,7 +368,7 @@ def _handle_unknown_event(self, request: Request) -> Response: def _verify_webhook_request(self, request: Request) -> bool: return verify_signature(request) - def _handle_personal_member_add(self, request: Request): + def _handle_personal_member_add(self, request: Request) -> Response: if not options.get("msteams.personal-installation-link.enabled"): return self.respond(status=204) @@ -517,7 +517,9 @@ def _make_action_data(self, data: Mapping[str, Any], user_id: int) -> dict[str, "unassign": MsTeamsIntegrationUnassign, } - def _issue_state_change(self, group: Group, identity: RpcIdentity, data) -> Response: + def _issue_state_change( + self, group: Group, identity: RpcIdentity, data: Mapping[str, Any] + ) -> Response: event_write_key = ApiKey( organization_id=group.project.organization_id, scope_list=["event:write"] ) diff --git a/src/sentry/models/authprovider.py b/src/sentry/models/authprovider.py index bb6419587f2b..46081b13ec9a 100644 --- a/src/sentry/models/authprovider.py +++ b/src/sentry/models/authprovider.py @@ -2,7 +2,7 @@ import logging from collections.abc import Mapping -from typing import Any +from typing import TYPE_CHECKING, Any from django.db import models from django.utils import timezone @@ -20,6 +20,10 @@ from sentry.hybridcloud.outbox.category import OutboxCategory, OutboxScope from sentry.types.cell import find_cells_for_orgs +if TYPE_CHECKING: + from sentry.auth.provider import Provider + from sentry.users.models.user import User + logger = logging.getLogger("sentry.authprovider") SCIM_INTERNAL_INTEGRATION_OVERVIEW = ( @@ -90,7 +94,7 @@ class Meta: def __str__(self) -> str: return self.provider - def get_provider(self): + def get_provider(self) -> Provider: from sentry.auth import manager return manager.get(self.provider, **self.config) @@ -99,10 +103,10 @@ def get_provider(self): def provider_name(self) -> str: return self.get_provider().name - def get_scim_token(self): + def get_scim_token(self) -> str | None: return get_scim_token(self.flags.scim_enabled, self.organization_id, self.provider) - def enable_scim(self, user): + def enable_scim(self, user: User) -> None: from sentry.sentry_apps.logic import SentryAppCreator from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation from sentry.sentry_apps.models.sentry_app_installation_for_provider import ( @@ -165,7 +169,7 @@ def outboxes_for_reset_idp_flags(self) -> list[ControlOutbox]: for region_name in find_cells_for_orgs([self.organization_id]) ] - def disable_scim(self): + def disable_scim(self) -> None: from sentry.sentry_apps.models.sentry_app_installation_for_provider import ( SentryAppInstallationForProvider, ) @@ -192,7 +196,7 @@ def disable_scim(self): pass self.flags.scim_enabled = False - def get_audit_log_data(self): + def get_audit_log_data(self) -> dict[str, object]: provider = self.provider # NOTE(isabella): for both standard fly SSO and fly-non-partner SSO, we should record the # provider as "fly" in the audit log entry data; the only difference between the two is diff --git a/src/sentry/models/projectownership.py b/src/sentry/models/projectownership.py index 4fb3bc18a06a..02bf45b8a8e7 100644 --- a/src/sentry/models/projectownership.py +++ b/src/sentry/models/projectownership.py @@ -69,7 +69,7 @@ class Meta: __repr__ = sane_repr("project_id", "is_active") @classmethod - def get_cache_key(self, project_id) -> str: + def get_cache_key(self, project_id: int) -> str: return f"projectownership_project_id:1:{project_id}" @classmethod @@ -93,7 +93,7 @@ def get_combined_schema( return ownership.schema @classmethod - def get_ownership_cached(cls, project_id): + def get_ownership_cached(cls, project_id: int) -> ProjectOwnership | None: """ Cached read access to projectownership. @@ -164,7 +164,7 @@ def get_owners( @classmethod def _hydrate_rules( cls, project_id: int, rules: Sequence[Rule], type: str = OwnerRuleType.OWNERSHIP_RULE.value - ): + ) -> list[tuple[Rule, Sequence[Team | RpcUser], str]]: """ Get the last matching rule to take the most precedence. """ @@ -174,7 +174,7 @@ def _hydrate_rules( for key, val in resolve_actors({owner for owner in owners}, project_id).items() if val } - result = [ + result: list[tuple[Rule, Sequence[Team | RpcUser], str]] = [ ( rule, Actor.resolve_many([actors[owner] for owner in rule.owners if owner in actors]), @@ -242,7 +242,7 @@ def get_issue_owners( return rules_with_owners @classmethod - def _get_autoassignment_types(cls, ownership): + def _get_autoassignment_types(cls, ownership: ProjectOwnership) -> list[int]: from sentry.models.groupowner import GroupOwnerType autoassignment_types = [] @@ -267,7 +267,7 @@ def handle_auto_assignment( group: Group | None = None, organization_id: int | None = None, force_autoassign: bool = False, - logging_extra: dict[str, str | bool | int] | None = None, + logging_extra: dict[str, object] | None = None, ) -> None: """ Get the auto-assign owner for a project if there are any. @@ -445,7 +445,7 @@ def _matching_ownership_rules( return [rule for rule in rules if rule.test(data, munged_data)] -def process_resource_change(instance, change, **kwargs): +def process_resource_change(instance: ProjectOwnership, change: str, **kwargs: object) -> None: from sentry.models.groupowner import GroupOwner from sentry.models.projectownership import ProjectOwnership diff --git a/src/sentry/relay/utils.py b/src/sentry/relay/utils.py index 801e26c45a2f..7e6d32d253ac 100644 --- a/src/sentry/relay/utils.py +++ b/src/sentry/relay/utils.py @@ -1,26 +1,42 @@ import uuid +from typing import TypeVar, overload +from django.http import HttpRequest -def get_header_relay_id(request): + +def get_header_relay_id(request: HttpRequest) -> str | None: try: return str(uuid.UUID(request.META["HTTP_X_SENTRY_RELAY_ID"])) except (LookupError, ValueError, TypeError): pass + return None -def get_header_relay_signature(request): +def get_header_relay_signature(request: HttpRequest) -> str | None: try: return str(request.META["HTTP_X_SENTRY_RELAY_SIGNATURE"]) except (LookupError, ValueError, TypeError): pass + return None -def type_to_class_name(snake_str): +def type_to_class_name(snake_str: str) -> str: components = snake_str.split("_") return "".join(x.title() for x in components[0:]) -def to_camel_case_name(name): +T = TypeVar("T") + + +@overload +def to_camel_case_name(name: str) -> str: ... + + +@overload +def to_camel_case_name(name: T) -> T: ... + + +def to_camel_case_name(name: object) -> object: """ Converts a string from snake_case to camelCase @@ -45,10 +61,10 @@ def to_camel_case_name(name): 'oneTwoThreeFour' """ - def first_lower(s): + def first_lower(s: str) -> str: return s[:1].lower() + s[1:] - def first_upper(s): + def first_upper(s: str) -> str: return s[:1].upper() + s[1:] if not isinstance(name, str): diff --git a/src/sentry/tasks/files.py b/src/sentry/tasks/files.py index 9f1f3433ddbb..93930a9a9aab 100644 --- a/src/sentry/tasks/files.py +++ b/src/sentry/tasks/files.py @@ -1,4 +1,8 @@ +from __future__ import annotations + +from collections.abc import Sequence from datetime import timedelta +from typing import TYPE_CHECKING from django.db import DatabaseError, IntegrityError, router from django.utils import timezone @@ -9,6 +13,12 @@ from sentry.taskworker.namespaces import deletion_control_tasks, deletion_tasks from sentry.utils.db import atomic_transaction +if TYPE_CHECKING: + from sentry.models.files.control_fileblob import ControlFileBlob + from sentry.models.files.control_fileblobindex import ControlFileBlobIndex + from sentry.models.files.fileblob import FileBlob + from sentry.models.files.fileblobindex import FileBlobIndex + MAX_RETRIES = 5 @@ -22,7 +32,7 @@ ), silo_mode=SiloMode.CELL, ) -def delete_file_region(path, checksum, **kwargs): +def delete_file_region(path: str | None, checksum: str, **kwargs: object) -> None: from sentry.models.files import FileBlob delete_file(FileBlob, path, checksum, **kwargs) @@ -38,13 +48,18 @@ def delete_file_region(path, checksum, **kwargs): ), silo_mode=SiloMode.CONTROL, ) -def delete_file_control(path, checksum, **kwargs): +def delete_file_control(path: str | None, checksum: str, **kwargs: object) -> None: from sentry.models.files import ControlFileBlob delete_file(ControlFileBlob, path, checksum, **kwargs) -def delete_file(file_blob_model, path, checksum, **kwargs): +def delete_file( + file_blob_model: type[FileBlob] | type[ControlFileBlob], + path: str | None, + checksum: str, + **kwargs: object, +) -> None: from sentry.models.files.utils import get_storage # check that the fileblob with *this* path exists, as its possible @@ -64,7 +79,7 @@ def delete_file(file_blob_model, path, checksum, **kwargs): ), silo_mode=SiloMode.CELL, ) -def delete_unreferenced_blobs_region(blob_ids): +def delete_unreferenced_blobs_region(blob_ids: Sequence[int]) -> None: from sentry.models.files.fileblob import FileBlob from sentry.models.files.fileblobindex import FileBlobIndex @@ -81,14 +96,18 @@ def delete_unreferenced_blobs_region(blob_ids): ), silo_mode=SiloMode.CONTROL, ) -def delete_unreferenced_blobs_control(blob_ids): +def delete_unreferenced_blobs_control(blob_ids: Sequence[int]) -> None: from sentry.models.files.control_fileblob import ControlFileBlob from sentry.models.files.control_fileblobindex import ControlFileBlobIndex delete_unreferenced_blobs(ControlFileBlob, ControlFileBlobIndex, blob_ids) -def delete_unreferenced_blobs(blob_model, blob_index_model, blob_ids): +def delete_unreferenced_blobs( + blob_model: type[FileBlob] | type[ControlFileBlob], + blob_index_model: type[FileBlobIndex] | type[ControlFileBlobIndex], + blob_ids: Sequence[int], +) -> None: for blob_id in blob_ids: # If a blob is referenced, we do not want to delete it if blob_index_model.objects.filter(blob_id=blob_id).exists(): diff --git a/src/sentry/types/actor.py b/src/sentry/types/actor.py index 3311c262e5bf..b08d05a538ca 100644 --- a/src/sentry/types/actor.py +++ b/src/sentry/types/actor.py @@ -1,7 +1,7 @@ from collections import defaultdict from collections.abc import Iterable, MutableMapping, Sequence from enum import Enum -from typing import TYPE_CHECKING, Any, Protocol, Union, overload +from typing import TYPE_CHECKING, Any, Literal, Protocol, Union, overload from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers @@ -39,10 +39,22 @@ class InvalidActor(ObjectDoesNotExist): pass + @overload + @classmethod + def resolve_many( + cls, actors: Sequence["Actor"], filter_none: Literal[True] = True + ) -> list["Team | RpcUser"]: ... + + @overload + @classmethod + def resolve_many( + cls, actors: Sequence["Actor"], filter_none: Literal[False] + ) -> list["Team | RpcUser | None"]: ... + @classmethod def resolve_many( cls, actors: Sequence["Actor"], filter_none: bool = True - ) -> list["Team | RpcUser | None"]: + ) -> Sequence["Team | RpcUser | None"]: """ Resolve a list of actors in a batch to the Team/User the Actor references. diff --git a/src/sentry/web/frontend/auth_login.py b/src/sentry/web/frontend/auth_login.py index 80d4245ac320..dbb4e2566a87 100644 --- a/src/sentry/web/frontend/auth_login.py +++ b/src/sentry/web/frontend/auth_login.py @@ -18,18 +18,18 @@ class AdditionalContext: - def __init__(self): - self._callbacks = set() + def __init__(self) -> None: + self._callbacks: set[Callable[[HttpRequest], dict[str, object]]] = set() - def add_callback(self, callback: Callable[[HttpRequest], dict]) -> None: + def add_callback(self, callback: Callable[[HttpRequest], dict[str, object]]) -> None: """ Callback should take a request object and return a dict of key-value pairs to add to the context. """ self._callbacks.add(callback) - def run_callbacks(self, request: HttpRequest) -> dict: - context = {} + def run_callbacks(self, request: HttpRequest) -> dict[str, object]: + context: dict[str, object] = {} for cb in self._callbacks: try: result = cb(request) @@ -43,6 +43,7 @@ def run_callbacks(self, request: HttpRequest) -> dict: @control_silo_view +@method_decorator(never_cache, name="handle") class AuthPageView(BaseView, ReactMixin): auth_required = False enforce_rate_limit = True @@ -50,11 +51,7 @@ class AuthPageView(BaseView, ReactMixin): limit_overrides={"GET": {RateLimitCategory.IP: RateLimit(limit=20, window=1)}} ) - @method_decorator(never_cache) - def handle(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase: - return super().handle(request, *args, **kwargs) - - def get(self, request: HttpRequest, **kwargs) -> HttpResponseBase: + def get(self, request: HttpRequest, **kwargs: object) -> HttpResponseBase: customer_domain_redirect = self.get_customer_domain_login_redirect(request) if customer_domain_redirect is not None: return customer_domain_redirect diff --git a/src/sentry/web/frontend/release_webhook.py b/src/sentry/web/frontend/release_webhook.py index 7056ae520482..ce33cbd1e045 100644 --- a/src/sentry/web/frontend/release_webhook.py +++ b/src/sentry/web/frontend/release_webhook.py @@ -3,6 +3,7 @@ from hashlib import sha256 from django.http import HttpRequest, HttpResponse +from django.http.response import HttpResponseBase from django.utils.crypto import constant_time_compare from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt @@ -20,7 +21,7 @@ @cell_silo_view class ReleaseWebhookView(View): - def verify(self, plugin_id, project_id, token, signature): + def verify(self, plugin_id: str, project_id: str, token: str, signature: str) -> bool: return constant_time_compare( signature, hmac.new( @@ -31,10 +32,10 @@ def verify(self, plugin_id, project_id, token, signature): ) @method_decorator(csrf_exempt) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponseBase: + return super().dispatch(request, *args, **kwargs) - def _handle_builtin(self, request: HttpRequest, project): + def _handle_builtin(self, request: HttpRequest, project: Project) -> HttpResponse: endpoint = f"/projects/{project.organization.slug}/{project.slug}/releases/" try: @@ -63,7 +64,9 @@ def _handle_builtin(self, request: HttpRequest, project): status=resp.status_code, content=json.dumps(resp.data), content_type="application/json" ) - def post(self, request: HttpRequest, plugin_id, project_id, signature) -> HttpResponse: + def post( + self, request: HttpRequest, plugin_id: str, project_id: str, signature: str + ) -> HttpResponse: try: project = Project.objects.get_from_cache(id=project_id) except ValueError: From 246c4ed22d381275cd070b253e396608a769a396 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:40:38 -0700 Subject: [PATCH 051/103] ref(typing): integrate lane 10 and eighteen wildcard families Graduate lane 10's modules and finish the remaining helpers in the three-error wildcard packages. Provider payloads and webhook data stay dynamic at their decode boundaries, with fail-loud guards where a cell response or group must exist. --- pyproject.toml | 29 ------------------- src/sentry/apidocs/spectacular_ports.py | 16 ++++++---- src/sentry/identity/gitlab/provider.py | 29 ++++++++++--------- src/sentry/identity/google/provider.py | 14 +++++---- .../api/bases/doc_integrations.py | 8 +++-- .../integrations/api/bases/external_actor.py | 4 ++- .../middleware/hybrid_cloud/parser.py | 13 ++++++--- .../integrations/msteams/actions/form.py | 3 +- .../msteams/actions/notification.py | 6 ++-- src/sentry/integrations/msteams/webhook.py | 5 +++- .../slack/message_builder/base/block.py | 10 ++++--- .../slack/message_builder/issues.py | 11 ++++--- src/sentry/seer/models/seer_api_models.py | 6 ++-- src/sentry/sentry_apps/tasks/sentry_apps.py | 5 ++-- src/sentry/sentry_apps/tasks/service_hooks.py | 7 ++++- src/sentry/sentry_apps/utils/webhooks.py | 4 +-- tests/sentry/analytics/test_base.py | 11 ++++--- tests/sentry/analytics/test_event.py | 2 +- .../sentry/auth/providers/fly/test_client.py | 7 +++-- .../auth/providers/fly/test_provider.py | 9 ++++-- .../auth/providers/google/test_provider.py | 11 ++++--- .../test_organization_conduit_demo.py | 2 +- tests/sentry/conduit/test_tasks.py | 8 +++-- .../api/bases/test_integration.py | 6 ++-- .../logic/test_incident_occurrence.py | 8 ++--- .../sentry/monitors/logic/test_mark_failed.py | 6 ++-- .../tasks/test_detect_broken_monitor_envs.py | 14 ++++++--- .../test_notification_actions_details.py | 12 ++++++-- .../test_notification_actions_index.py | 4 ++- tests/sentry/pipeline/test_pipeline.py | 9 ++++-- tests/sentry/uptime/rdap/test_query.py | 8 +++-- tests/snuba/tsdb/test_tsdb_backend.py | 14 +++++++-- 32 files changed, 175 insertions(+), 126 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2bc3c853a9c9..5b6634441710 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -471,7 +471,6 @@ module = [ "sentry.api.endpoints.organization_unsubscribe", "sentry.api.endpoints.project_artifact_bundle_files", "sentry.api.endpoints.project_trace_item_details", - "sentry.api.endpoints.project_transaction_threshold", "sentry.api.endpoints.project_transaction_threshold_override", "sentry.api.endpoints.relay.*", "sentry.api.fields.*", @@ -481,12 +480,10 @@ module = [ "sentry.api.serializers.models.group_stream", "sentry.api.serializers.models.groupseen", "sentry.api.serializers.models.project", - "sentry.api.serializers.models.release", "sentry.api.serializers.models.userrollback", "sentry.api.serializers.rest_framework.base", "sentry.api.serializers.rest_framework.dashboard", "sentry.api.serializers.snuba", - "sentry.apidocs.spectacular_ports", "sentry.assistant.*", "sentry.attachments.*", "sentry.backup.*", @@ -507,7 +504,6 @@ module = [ "sentry.db.router", "sentry.debug_files.*", "sentry.discover.arithmetic", - "sentry.discover.dataset_split", "sentry.discover.endpoints.*", "sentry.discover.translation.*", "sentry.explore.endpoints.*", @@ -521,8 +517,6 @@ module = [ "sentry.grouping.strategies.*", "sentry.http", "sentry.identity.github.*", - "sentry.identity.gitlab.*", - "sentry.identity.google.*", "sentry.identity.manager", "sentry.identity.oauth2", "sentry.identity.slack.*", @@ -531,7 +525,6 @@ module = [ "sentry.ingest.consumer.*", "sentry.ingest.inbound_filters", "sentry.ingest.transaction_clusterer.*", - "sentry.integrations.api.bases.*", "sentry.integrations.api.endpoints.data_forwarding_details", "sentry.integrations.api.endpoints.data_forwarding_index", "sentry.integrations.api.endpoints.doc_integrations_index", @@ -558,17 +551,13 @@ module = [ "sentry.integrations.jira_server.client", "sentry.integrations.jira_server.integration", "sentry.integrations.jira_server.webhooks", - "sentry.integrations.middleware.*", "sentry.integrations.mixins.*", "sentry.integrations.models.integration", - "sentry.integrations.msteams.actions.*", "sentry.integrations.msteams.utils", - "sentry.integrations.msteams.webhook", "sentry.integrations.on_call.*", "sentry.integrations.pipeline", "sentry.integrations.slack.actions.*", "sentry.integrations.slack.integration", - "sentry.integrations.slack.message_builder.*", "sentry.integrations.time_utils", "sentry.integrations.vsts.client", "sentry.interfaces.*", @@ -589,7 +578,6 @@ module = [ "sentry.management.*", "sentry.middleware.devtoolbar", "sentry.models.artifactbundle", - "sentry.models.authprovider", "sentry.models.avatars.*", "sentry.models.commit", "sentry.models.commitfilechange", @@ -610,7 +598,6 @@ module = [ "sentry.models.organizationmemberinvite", "sentry.models.project", "sentry.models.projectcodeowners", - "sentry.models.projectownership", "sentry.models.recentsearch", "sentry.models.release", "sentry.models.release_threshold.*", @@ -640,7 +627,6 @@ module = [ "sentry.receivers.*", "sentry.relay.projectconfig_cache.*", "sentry.relay.projectconfig_debounce_cache.*", - "sentry.relay.utils", "sentry.release_health.eap_sessions_rollout", "sentry.releases.endpoints.*", "sentry.remote_subscriptions.*", @@ -662,14 +648,12 @@ module = [ "sentry.seer.autofix.*", "sentry.seer.endpoints.*", "sentry.seer.entrypoints.*", - "sentry.seer.models.*", "sentry.sentry_apps.api.bases.*", "sentry.sentry_apps.api.endpoints.*", "sentry.sentry_apps.api.parsers.*", "sentry.sentry_apps.api.serializers.*", "sentry.sentry_apps.logic", "sentry.sentry_apps.models.*", - "sentry.sentry_apps.tasks.*", "sentry.sentry_metrics.querying.*", "sentry.services.filestore.*", "sentry.services.http", @@ -692,7 +676,6 @@ module = [ "sentry.tasks.auto_ongoing_issues", "sentry.tasks.auto_resolve_issues", "sentry.tasks.collect_project_platforms", - "sentry.tasks.files", "sentry.tasks.relay", "sentry.tasks.seer.backfill_supergroups_lightweight", "sentry.tasks.seer.context_engine_index", @@ -745,12 +728,10 @@ module = [ "sentry.utils.validators", "sentry.web.api", "sentry.web.forms.*", - "sentry.web.frontend.auth_login", "sentry.web.frontend.disabled_member_view", "sentry.web.frontend.generic", "sentry.web.frontend.mailgun_inbound_webhook", "sentry.web.frontend.react_page", - "sentry.web.frontend.release_webhook", "sentry.web.frontend.setup_wizard", "social_auth.backends.*", "social_auth.fields", @@ -761,7 +742,6 @@ module = [ "tests.integration.*", "tests.relay_integration.*", "tests.sentry", - "tests.sentry.analytics.*", "tests.sentry.api", "tests.sentry.api.bases.*", "tests.sentry.api.endpoints", @@ -902,7 +882,6 @@ module = [ "tests.sentry.api.test_utils", "tests.sentry.apidocs.*", "tests.sentry.attachments.*", - "tests.sentry.auth.providers.fly.*", "tests.sentry.auth.providers.github.*", "tests.sentry.auth.providers.test_oauth2", "tests.sentry.auth.providers.test_saml2", @@ -920,7 +899,6 @@ module = [ "tests.sentry.billing.*", "tests.sentry.buffer.*", "tests.sentry.cache.*", - "tests.sentry.conduit.*", "tests.sentry.consumers.*", "tests.sentry.core.*", "tests.sentry.dashboards.*", @@ -963,7 +941,6 @@ module = [ "tests.sentry.incidents.utils.*", "tests.sentry.ingest.*", "tests.sentry.integrations", - "tests.sentry.integrations.api.bases.*", "tests.sentry.integrations.api.endpoints.*", "tests.sentry.integrations.bitbucket.*", "tests.sentry.integrations.claude_code.*", @@ -1091,9 +1068,7 @@ module = [ "tests.sentry.monitors", "tests.sentry.monitors.consumers.*", "tests.sentry.monitors.endpoints.*", - "tests.sentry.monitors.logic.*", "tests.sentry.monitors.processing_errors.*", - "tests.sentry.monitors.tasks.*", "tests.sentry.monitors.test_clock_dispatch", "tests.sentry.monitors.test_models", "tests.sentry.monitors.test_rate_limit", @@ -1103,7 +1078,6 @@ module = [ "tests.sentry.monitors.test_types", "tests.sentry.monitors.test_utils", "tests.sentry.monitors.test_validators", - "tests.sentry.notifications.api.*", "tests.sentry.notifications.notification_action.test_group_type_notification_registry_handlers", "tests.sentry.notifications.notification_action.test_issue_alert_registry_handlers", "tests.sentry.notifications.notification_action.test_metric_alert_registry_handlers", @@ -1113,7 +1087,6 @@ module = [ "tests.sentry.notifications.test_notifications", "tests.sentry.notifications.test_utils", "tests.sentry.options.*", - "tests.sentry.pipeline.*", "tests.sentry.preprod.*", "tests.sentry.processing_errors.*", "tests.sentry.profiles.test_flamegraph", @@ -1292,7 +1265,6 @@ module = [ "tests.sentry.uptime", "tests.sentry.uptime.consumers.*", "tests.sentry.uptime.endpoints.*", - "tests.sentry.uptime.rdap.*", "tests.sentry.uptime.subscriptions.*", "tests.sentry.uptime.test_grouptype", "tests.sentry.uptime.test_models", @@ -1423,7 +1395,6 @@ module = [ "tests.snuba.test_snql_snuba", "tests.snuba.test_snuba", "tests.snuba.test_util", - "tests.snuba.tsdb.*", "tests.symbolicator.*", "tests.tools.*", ] diff --git a/src/sentry/apidocs/spectacular_ports.py b/src/sentry/apidocs/spectacular_ports.py index f9d44ed3464b..31555ffebb07 100644 --- a/src/sentry/apidocs/spectacular_ports.py +++ b/src/sentry/apidocs/spectacular_ports.py @@ -67,12 +67,16 @@ # map TypedDicts in schema registry +# justification: drf-spectacular introspects arbitrary runtime types. +TypeHint = Any + + def get_type_hints( - hint: Any, - globalns: dict[str, Any] | None = None, - localns: dict[str, Any] | None = None, + hint: TypeHint, + globalns: dict[str, TypeHint] | None = None, + localns: dict[str, TypeHint] | None = None, include_extras: bool = False, -) -> dict[str, Any]: +) -> dict[str, TypeHint]: try: return _get_type_hints(hint, globalns, localns, include_extras) except NameError: @@ -88,7 +92,7 @@ def get_type_hints( ) -def _get_type_hint_origin(hint: Any) -> tuple[Any, tuple[Any, ...]]: +def _get_type_hint_origin(hint: TypeHint) -> tuple[TypeHint, tuple[TypeHint, ...]]: return typing.get_origin(hint), typing.get_args(hint) @@ -130,7 +134,7 @@ def _cycle_placeholder(hint: Any) -> dict[str, Any]: } -def resolve_type_hint(hint: Any) -> Any: +def resolve_type_hint(hint: TypeHint) -> TypeHint: """drf-spectacular library method modified as described above""" origin, args = _get_type_hint_origin(hint) excluded_fields = get_override(hint, "exclude_fields", []) diff --git a/src/sentry/identity/gitlab/provider.py b/src/sentry/identity/gitlab/provider.py index ca2e1677a18a..a3ad8ff938d1 100644 --- a/src/sentry/identity/gitlab/provider.py +++ b/src/sentry/identity/gitlab/provider.py @@ -1,8 +1,9 @@ from __future__ import annotations import logging +from collections.abc import Mapping from time import time -from typing import Any +from typing import Any, cast import orjson @@ -22,7 +23,7 @@ ACCESS_TOKEN_LIFETIME_S = 2 * 60 * 60 -def get_oauth_data(payload): +def get_oauth_data(payload: Mapping[str, object]) -> dict[str, object]: data = {"access_token": payload["access_token"]} # https://docs.gitlab.com/ee/api/oauth2.html#2-requesting-access-token @@ -38,23 +39,23 @@ def get_oauth_data(payload): # Measured on our clock rather than from GitLab's created_at, since it's compared against # our clock. GitLab before 15.0 can issue a token without an expiry, and gives it one when # the instance upgrades; assuming the usual lifetime refreshes it often enough either way. - data["expires_in"] = int(payload.get("expires_in", ACCESS_TOKEN_LIFETIME_S)) - data["expires"] = int(time()) + data["expires_in"] + data["expires_in"] = int(cast(int, payload.get("expires_in", ACCESS_TOKEN_LIFETIME_S))) + data["expires"] = int(time()) + cast(int, data["expires_in"]) if "refresh_token" in payload: data["refresh_token"] = payload["refresh_token"] if "token_type" in payload: data["token_type"] = payload["token_type"] if "created_at" in payload: - data["created_at"] = int(payload["created_at"]) + data["created_at"] = int(cast(int, payload["created_at"])) return data -def get_user_info(access_token, installation_data): +def get_user_info(access_token: str, installation_data: Mapping[str, object]) -> dict[str, object]: with http.build_session() as session: resp = session.get( f"{installation_data['url']}/api/v4/user", headers={"Accept": "application/json", "Authorization": f"Bearer {access_token}"}, - verify=installation_data["verify_ssl"], + verify=cast(bool, installation_data["verify_ssl"]), ) try: resp.raise_for_status() @@ -79,15 +80,17 @@ class GitlabIdentityProvider(OAuth2Provider): oauth_scopes = ("api",) - def build_identity(self, data): - data = data["data"] + def build_identity(self, data: Mapping[str, object]) -> Mapping[str, object]: + # justification: provider OAuth responses are decoded JSON. + identity_data = cast(Mapping[str, object], data["data"]) + user = cast(Mapping[str, object], identity_data["user"]) return { "type": IntegrationProviderSlug.GITLAB.value, - "id": data["user"]["id"], - "email": data["user"]["email"], - "scopes": sorted(data["scope"].split(",")), - "data": self.get_oauth_data(data), + "id": user["id"], + "email": user["email"], + "scopes": sorted(cast(str, identity_data["scope"]).split(",")), + "data": self.get_oauth_data(identity_data), } def get_refresh_token_params( diff --git a/src/sentry/identity/google/provider.py b/src/sentry/identity/google/provider.py index 10d74c57d9c5..b469eedaf7fb 100644 --- a/src/sentry/identity/google/provider.py +++ b/src/sentry/identity/google/provider.py @@ -1,4 +1,5 @@ -from typing import Any +from collections.abc import Mapping +from typing import Any, cast import orjson @@ -33,17 +34,18 @@ class GoogleIdentityProvider(OAuth2Provider): oauth_scopes = ("email",) - def get_oauth_client_id(self): + def get_oauth_client_id(self) -> str: return options.get("auth-google.client-id") - def get_oauth_client_secret(self): + def get_oauth_client_secret(self) -> str: return options.get("auth-google.client-secret") - def build_identity(self, state): - data = state.get("data", {}) + def build_identity(self, state: Mapping[str, object]) -> Mapping[str, object]: + # justification: provider OAuth responses are decoded JSON. + data = cast(Mapping[str, object], state.get("data", {})) try: - id_token = data["id_token"] + id_token = cast(str, data["id_token"]) except KeyError: raise IdentityNotValid("Missing id_token in OAuth response: %s" % data) diff --git a/src/sentry/integrations/api/bases/doc_integrations.py b/src/sentry/integrations/api/bases/doc_integrations.py index b0d286d112fb..668cd38039cb 100644 --- a/src/sentry/integrations/api/bases/doc_integrations.py +++ b/src/sentry/integrations/api/bases/doc_integrations.py @@ -78,8 +78,12 @@ class DocIntegrationBaseEndpoint(DocIntegrationsBaseEndpoint): """ def convert_args( - self, request: Request, doc_integration_id_or_slug: int | str, *args, **kwargs - ): + self, + request: Request, + doc_integration_id_or_slug: int | str, + *args: object, + **kwargs: object, + ) -> tuple[tuple[object, ...], dict[str, object]]: try: doc_integration = DocIntegration.objects.get( slug__id_or_slug=doc_integration_id_or_slug diff --git a/src/sentry/integrations/api/bases/external_actor.py b/src/sentry/integrations/api/bases/external_actor.py index 98364da365b8..5ae026c0f456 100644 --- a/src/sentry/integrations/api/bases/external_actor.py +++ b/src/sentry/integrations/api/bases/external_actor.py @@ -63,7 +63,9 @@ class ExternalActorResponse(TypedDict): integration_id: int -class ExternalActorSerializerBase(CamelSnakeModelSerializer): +class ExternalActorSerializerBase( + CamelSnakeModelSerializer[Any] # justification: create returns a (instance, created) pair. +): external_id = serializers.CharField( required=False, allow_null=True, help_text="The associated user ID for provider." ) diff --git a/src/sentry/integrations/middleware/hybrid_cloud/parser.py b/src/sentry/integrations/middleware/hybrid_cloud/parser.py index b6791fa2e1cd..aba9beb7e1db 100644 --- a/src/sentry/integrations/middleware/hybrid_cloud/parser.py +++ b/src/sentry/integrations/middleware/hybrid_cloud/parser.py @@ -94,7 +94,7 @@ def __init__(self, request: HttpRequest, response_handler: ResponseHandler): # Common Helpers - def ensure_control_silo(self): + def ensure_control_silo(self) -> None: with MiddlewareOperationEvent( operation_type=MiddlewareOperationType.ENSURE_CONTROL_SILO, integration_name=self.provider, @@ -413,7 +413,7 @@ def mailbox_event_type(self, data: dict[str, Any]) -> str | None: """ return None - def get_response_from_first_cell(self): + def get_response_from_first_cell(self) -> HttpResponseBase: cells = self.get_cells_from_organizations() first_cell = cells[0] response_map = self.get_responses_from_cell_silos(cells=[first_cell]) @@ -432,9 +432,11 @@ def get_response_from_first_cell(self): if cell_result.error is not None: # We want to fail loudly so that devs know this error happened on the cell silo (for now) raise SiloClientError(cell_result.error) + if cell_result.response is None: + raise SiloClientError("Cell returned no response") return cell_result.response - def get_response_from_all_cells(self): + def get_response_from_all_cells(self) -> HttpResponseBase: cells = self.get_cells_from_organizations() response_map = self.get_responses_from_cell_silos(cells=cells) successful_responses = [ @@ -450,7 +452,10 @@ def get_response_from_all_cells(self): f"{cell}: {result.error}" for cell, result in response_map.items() ) raise SiloClientError("No successful cell responses", error_map_str) - return successful_responses[0].response + response = successful_responses[0].response + if response is None: + raise SiloClientError("Cell returned no response") + return response # Required Overrides diff --git a/src/sentry/integrations/msteams/actions/form.py b/src/sentry/integrations/msteams/actions/form.py index 758321c1d95d..5332a4ba8551 100644 --- a/src/sentry/integrations/msteams/actions/form.py +++ b/src/sentry/integrations/msteams/actions/form.py @@ -17,7 +17,8 @@ class MsTeamsNotifyServiceForm(forms.Form): channel = forms.CharField(widget=forms.TextInput()) channel_id = forms.HiddenInput() - def __init__(self, *args, **kwargs): + # justification: Django form kwargs are dynamic. + def __init__(self, *args: Any, **kwargs: Any) -> None: self._team_list = [(i.id, i.name) for i in kwargs.pop("integrations")] super().__init__(*args, **kwargs) diff --git a/src/sentry/integrations/msteams/actions/notification.py b/src/sentry/integrations/msteams/actions/notification.py index 5bd49aea8eba..feac167deaef 100644 --- a/src/sentry/integrations/msteams/actions/notification.py +++ b/src/sentry/integrations/msteams/actions/notification.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Generator, Sequence +from typing import Any from sentry.integrations.messaging.metrics import ( MessagingInteractionEvent, @@ -29,7 +30,8 @@ class MsTeamsNotifyServiceAction(IntegrationEventAction): provider = IntegrationProviderSlug.MSTEAMS.value integration_key = "team" - def __init__(self, *args, **kwargs): + # justification: Django form kwargs are dynamic. + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.form_fields = { "team": { @@ -88,7 +90,7 @@ def send_notification(event: GroupEvent, futures: Sequence[RuleFuture]) -> None: ) yield self.future(send_notification, key=key) - def render_label(self): + def render_label(self) -> str: return self.label.format( team=self.get_integration_name(), channel=self.get_option("channel") ) diff --git a/src/sentry/integrations/msteams/webhook.py b/src/sentry/integrations/msteams/webhook.py index 3adfa672d615..60cadaccdff6 100644 --- a/src/sentry/integrations/msteams/webhook.py +++ b/src/sentry/integrations/msteams/webhook.py @@ -518,7 +518,10 @@ def _make_action_data(self, data: Mapping[str, Any], user_id: int) -> dict[str, } def _issue_state_change( - self, group: Group, identity: RpcIdentity, data: Mapping[str, Any] + self, + group: Group, + identity: RpcIdentity, + data: Mapping[str, Any], # justification: webhook payload is JSON. ) -> Response: event_write_key = ApiKey( organization_id=group.project.organization_id, scope_list=["event:write"] diff --git a/src/sentry/integrations/slack/message_builder/base/block.py b/src/sentry/integrations/slack/message_builder/base/block.py index af471bcc1417..7b37dca8710b 100644 --- a/src/sentry/integrations/slack/message_builder/base/block.py +++ b/src/sentry/integrations/slack/message_builder/base/block.py @@ -77,16 +77,18 @@ def get_divider() -> SlackBlock: return {"type": "divider"} @staticmethod - def get_external_select_action(action, initial_option): - action = { + def get_external_select_action( + action: MessageAction, initial_option: Mapping[str, object] | None + ) -> SlackBlock: + block: SlackBlock = { "type": "external_select", "placeholder": {"type": "plain_text", "text": action.label, "emoji": True}, "action_id": action.action_id if action.action_id else action.name, } if initial_option: - action["initial_option"] = initial_option + block["initial_option"] = initial_option - return action + return block @staticmethod def get_button_action(action: MessageAction) -> SlackBlock: diff --git a/src/sentry/integrations/slack/message_builder/issues.py b/src/sentry/integrations/slack/message_builder/issues.py index 5fc1c8ed4170..f6db996c1fcd 100644 --- a/src/sentry/integrations/slack/message_builder/issues.py +++ b/src/sentry/integrations/slack/message_builder/issues.py @@ -2,7 +2,7 @@ import logging from collections.abc import Callable, Mapping, Sequence -from datetime import datetime +from datetime import date, datetime from typing import Any, TypedDict import orjson @@ -86,7 +86,10 @@ def get_group_users_count(group: Group, rules: list[NotificationOrigin] | None = # NOTE: if this starts getting large and functions get complicated, # pull things out into their own functions -SUPPORTED_CONTEXT_DATA: dict[NotificationContextField, Callable] = { +ContextDataFunc = Callable[[Group, list[NotificationOrigin] | None], str | int | date] + + +SUPPORTED_CONTEXT_DATA: dict[NotificationContextField, ContextDataFunc] = { NotificationContextField.EVENTS: lambda group, rules: get_group_global_count(group), NotificationContextField.USERS_AFFECTED: get_group_users_count, NotificationContextField.STATE: lambda group, rules: SUBSTATUS_TO_STR.get(group.substatus, "") @@ -207,7 +210,7 @@ def get_context(group: Group, rules: list[NotificationOrigin] | None = None) -> if NotificationContextField.STATE in context: state = SUPPORTED_CONTEXT_DATA[NotificationContextField.STATE](group, rules) if NotificationContextField.EVENTS in context: - event_count = SUPPORTED_CONTEXT_DATA[NotificationContextField.EVENTS](group, rules) + event_count = str(SUPPORTED_CONTEXT_DATA[NotificationContextField.EVENTS](group, rules)) if (state and state == "New") or (event_count and int(event_count) <= 1): if NotificationContextField.EVENTS in context: @@ -485,7 +488,7 @@ def get_culprit_block(self, event_or_group: Event | GroupEvent | Group) -> Slack return self.get_context_block(event_or_group.culprit) return None - def get_text_block(self, text) -> SlackBlock: + def get_text_block(self, text: str) -> SlackBlock: if self.group.issue_category == GroupCategory.FEEDBACK: max_block_text_length = USER_FEEDBACK_MAX_BLOCK_TEXT_LENGTH else: diff --git a/src/sentry/seer/models/seer_api_models.py b/src/sentry/seer/models/seer_api_models.py index 9b303a06021a..e41405fb30d3 100644 --- a/src/sentry/seer/models/seer_api_models.py +++ b/src/sentry/seer/models/seer_api_models.py @@ -118,7 +118,7 @@ def __init__(self, message: str, status: int): self.message = message self.status = status - def __str__(self): + def __str__(self) -> str: return f"Seer API error: {self.message} (status: {self.status})" @@ -130,7 +130,7 @@ class SeerApiResponseValidationError(Exception): def __init__(self, message: str): self.message = message - def __str__(self): + def __str__(self) -> str: return f"Seer API response validation error: {self.message}" @@ -138,7 +138,7 @@ class SeerPermissionError(Exception): def __init__(self, message: str): self.message = message - def __str__(self): + def __str__(self) -> str: return f"Seer permission error: {self.message}" diff --git a/src/sentry/sentry_apps/tasks/sentry_apps.py b/src/sentry/sentry_apps/tasks/sentry_apps.py index 57c2eb1ab3c4..936da4c04bf9 100644 --- a/src/sentry/sentry_apps/tasks/sentry_apps.py +++ b/src/sentry/sentry_apps/tasks/sentry_apps.py @@ -914,7 +914,7 @@ def send_webhooks(installation: RpcSentryAppInstallation, event: str, **kwargs: silo_mode=SiloMode.CONTROL, ) def create_or_update_service_hooks_for_sentry_app( - sentry_app_id: int, webhook_url: str, events: list[str], **kwargs: dict + sentry_app_id: int, webhook_url: str, events: list[str], **kwargs: object ) -> None: with SentryAppInteractionEvent( operation_type=SentryAppInteractionType.MANAGEMENT, @@ -1001,7 +1001,8 @@ def _record_metric_alert_sent_analytics( def _record_metric_alert_ui_component_analytics( organization_id: int, sentry_app_id: int, - app_platform_event: AppPlatformEvent, + # justification: app event bodies are arbitrary JSON mappings. + app_platform_event: AppPlatformEvent[Mapping[str, Any]], ) -> None: if not find_alert_rule_action_ui_component(app_platform_event): return diff --git a/src/sentry/sentry_apps/tasks/service_hooks.py b/src/sentry/sentry_apps/tasks/service_hooks.py index 8fe29b0f9d12..470f443b0d00 100644 --- a/src/sentry/sentry_apps/tasks/service_hooks.py +++ b/src/sentry/sentry_apps/tasks/service_hooks.py @@ -1,4 +1,5 @@ from time import time +from typing import Any from taskbroker_client.retry import Retry @@ -15,8 +16,12 @@ from sentry.utils.cache import cache -def get_payload_v0(event): +def get_payload_v0( + event: Event | GroupEvent, +) -> dict[str, Any]: # justification: webhook payload is JSON. group = event.group + if group is None: + raise ValueError("Cannot build a webhook payload for an ungrouped event") project = group.project group_context = serialize(group) diff --git a/src/sentry/sentry_apps/utils/webhooks.py b/src/sentry/sentry_apps/utils/webhooks.py index 7bc88ce1d5b6..18bc0a999f30 100644 --- a/src/sentry/sentry_apps/utils/webhooks.py +++ b/src/sentry/sentry_apps/utils/webhooks.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Collection +from collections.abc import Collection, Mapping from enum import StrEnum from typing import TYPE_CHECKING, Any, Final @@ -162,7 +162,7 @@ def is_subscribed(stored_events: Collection[str], event: str) -> bool: def find_alert_rule_action_ui_component( - app_platform_event: AppPlatformEvent[dict[str, Any]], + app_platform_event: AppPlatformEvent[Mapping[str, Any]], ) -> bool: """ Returns True if the metric alert event contains a sentry app action with UI component settings. diff --git a/tests/sentry/analytics/test_base.py b/tests/sentry/analytics/test_base.py index 7d4cd20525fa..e86f5fea4b88 100644 --- a/tests/sentry/analytics/test_base.py +++ b/tests/sentry/analytics/test_base.py @@ -1,3 +1,5 @@ +from typing import Any + from sentry.analytics import Analytics from sentry.analytics.event import EventEnvelope from sentry.analytics.events.organization_created import OrganizationCreatedEvent @@ -5,12 +7,13 @@ class DummyAnalytics(Analytics): - def __init__(self): - self.events = [] - self.event_envelopes = [] + def __init__(self) -> None: + # justification: subclasses declare the event attributes under test. + self.events: list[Any] = [] + self.event_envelopes: list[EventEnvelope] = [] super().__init__() - def record_event_envelope(self, envelope: EventEnvelope): + def record_event_envelope(self, envelope: EventEnvelope) -> None: self.event_envelopes.append(envelope) self.events.append(envelope.event) diff --git a/tests/sentry/analytics/test_event.py b/tests/sentry/analytics/test_event.py index f3bef17de07b..5e658f7397ab 100644 --- a/tests/sentry/analytics/test_event.py +++ b/tests/sentry/analytics/test_event.py @@ -18,7 +18,7 @@ class DummyType: @eventclass("example") class ExampleEvent(Event): id: int - map: dict | DummyType + map: dict[str, object] | DummyType optional: bool | None = None diff --git a/tests/sentry/auth/providers/fly/test_client.py b/tests/sentry/auth/providers/fly/test_client.py index 3510331368a6..9813b21bb2bd 100644 --- a/tests/sentry/auth/providers/fly/test_client.py +++ b/tests/sentry/auth/providers/fly/test_client.py @@ -1,4 +1,5 @@ from unittest import mock +from unittest.mock import MagicMock import pytest import responses @@ -8,12 +9,12 @@ @pytest.fixture -def client(): +def client() -> FlyClient: return FlyClient("accessToken") @responses.activate -def test_request_sends_access_token(client) -> None: +def test_request_sends_access_token(client: FlyClient) -> None: responses.add(responses.GET, f"{ACCESS_TOKEN_URL}/", json={"status": "SUCCESS"}, status=200) client._request("/") @@ -22,6 +23,6 @@ def test_request_sends_access_token(client) -> None: @mock.patch.object(FlyClient, "_request") -def test_get_info(mock_request, client) -> None: +def test_get_info(mock_request: MagicMock, client: FlyClient) -> None: client.get_info() mock_request.assert_called_once_with("/info") diff --git a/tests/sentry/auth/providers/fly/test_provider.py b/tests/sentry/auth/providers/fly/test_provider.py index f47531e93b43..f013ced27ef8 100644 --- a/tests/sentry/auth/providers/fly/test_provider.py +++ b/tests/sentry/auth/providers/fly/test_provider.py @@ -1,7 +1,10 @@ +from typing import cast + import pytest from sentry.auth.exceptions import IdentityNotValid from sentry.auth.partnership_configs import ChannelName +from sentry.auth.providers.fly.provider import FlyOAuth2Provider from sentry.models.authidentity import AuthIdentity from sentry.models.authprovider import AuthProvider from sentry.testutils.cases import TestCase @@ -21,20 +24,20 @@ def test_refresh_identity_without_refresh_token(self) -> None: auth_provider=self.auth_provider, user=self.user, data={"access_token": "access_token"} ) - provider = self.auth_provider.get_provider() + provider = cast(FlyOAuth2Provider, self.auth_provider.get_provider()) with pytest.raises(IdentityNotValid): provider.refresh_identity(auth_identity) def test_build_config(self) -> None: - provider = self.auth_provider.get_provider() + provider = cast(FlyOAuth2Provider, self.auth_provider.get_provider()) resource = {"id": "nathans-org", "role": "member"} result = provider.build_config(resource=resource) assert result == {"org": {"id": "nathans-org"}} assert provider.is_partner == (self.auth_provider.provider == ChannelName.FLY_IO.value) def test_build_identity(self) -> None: - provider = self.auth_provider.get_provider() + provider = cast(FlyOAuth2Provider, self.auth_provider.get_provider()) data = { "access_token": "fo1_6xgeCrB8ew8vFQ86vdaakBSFTVDGCzOUvebUbvgPGhI", "token_type": "Bearer", diff --git a/tests/sentry/auth/providers/google/test_provider.py b/tests/sentry/auth/providers/google/test_provider.py index 5dbda8893d00..5d9156c8fa7a 100644 --- a/tests/sentry/auth/providers/google/test_provider.py +++ b/tests/sentry/auth/providers/google/test_provider.py @@ -1,7 +1,10 @@ +from typing import cast + import pytest from sentry.auth.exceptions import IdentityNotValid from sentry.auth.providers.google.constants import DATA_VERSION +from sentry.auth.providers.google.provider import GoogleOAuth2Provider from sentry.models.authidentity import AuthIdentity from sentry.models.authprovider import AuthProvider from sentry.testutils.cases import TestCase @@ -23,7 +26,7 @@ def test_refresh_identity_without_refresh_token(self) -> None: data={"access_token": "access_token"}, ) - provider = self.auth_provider_inst.get_provider() + provider = cast(GoogleOAuth2Provider, self.auth_provider_inst.get_provider()) with pytest.raises(IdentityNotValid): provider.refresh_identity(auth_identity) @@ -31,17 +34,17 @@ def test_refresh_identity_without_refresh_token(self) -> None: def test_handles_multiple_domains(self) -> None: self.auth_provider_inst.update(config={"domains": ["example.com"]}) - provider = self.auth_provider_inst.get_provider() + provider = cast(GoogleOAuth2Provider, self.auth_provider_inst.get_provider()) assert provider.domains == ["example.com"] def test_handles_legacy_single_domain(self) -> None: self.auth_provider_inst.update(config={"domain": "example.com"}) - provider = self.auth_provider_inst.get_provider() + provider = cast(GoogleOAuth2Provider, self.auth_provider_inst.get_provider()) assert provider.domains == ["example.com"] def test_build_config(self) -> None: - provider = self.auth_provider_inst.get_provider() + provider = cast(GoogleOAuth2Provider, self.auth_provider_inst.get_provider()) state = { "domain": "example.com", "user": { diff --git a/tests/sentry/conduit/endpoints/test_organization_conduit_demo.py b/tests/sentry/conduit/endpoints/test_organization_conduit_demo.py index e1197850ef34..81de6fcfbfaa 100644 --- a/tests/sentry/conduit/endpoints/test_organization_conduit_demo.py +++ b/tests/sentry/conduit/endpoints/test_organization_conduit_demo.py @@ -123,7 +123,7 @@ def test_credentials_are_unique(self) -> None: ) @patch("sentry.conduit.endpoints.organization_conduit_demo.stream_demo_data") @with_feature("organizations:conduit-demo") - def test_post_queues_task(self, mock_task: MagicMock): + def test_post_queues_task(self, mock_task: MagicMock) -> None: self.get_success_response( self.organization.slug, method="POST", diff --git a/tests/sentry/conduit/test_tasks.py b/tests/sentry/conduit/test_tasks.py index 19ef6e999295..abe5f846a37a 100644 --- a/tests/sentry/conduit/test_tasks.py +++ b/tests/sentry/conduit/test_tasks.py @@ -1,7 +1,7 @@ import datetime import re import time -from unittest.mock import patch +from unittest.mock import MagicMock, patch from uuid import uuid4 import jwt as pyjwt @@ -260,7 +260,7 @@ class StreamDemoDataTest(TestCase): # Patch the module's own `time` reference. Patching `time.sleep` would also count the sleeps of # every other thread in the test process. @patch("sentry.conduit.tasks.time") - def test_stream_demo_data_sends_all_phases(self, mock_time): + def test_stream_demo_data_sends_all_phases(self, mock_time: MagicMock) -> None: """Test that stream_demo_data sends START, DELTA, and END phases.""" mock_time.time.side_effect = time.time org_id = 123 @@ -288,7 +288,9 @@ def test_stream_demo_data_sends_all_phases(self, mock_time): # `time.sleep`, which would replace sleep for every thread in the test process. @patch("sentry.utils.retries.time") @patch("sentry.conduit.tasks.time") - def test_stream_demo_data_stops_on_exhausted_retries(self, mock_time, mock_retries_time): + def test_stream_demo_data_stops_on_exhausted_retries( + self, mock_time: MagicMock, mock_retries_time: MagicMock + ) -> None: """Test that stream_demo_data stops streaming when a publish fails after max retries.""" mock_time.time.side_effect = time.time org_id = 123 diff --git a/tests/sentry/integrations/api/bases/test_integration.py b/tests/sentry/integrations/api/bases/test_integration.py index bda6829a097d..81beda601e90 100644 --- a/tests/sentry/integrations/api/bases/test_integration.py +++ b/tests/sentry/integrations/api/bases/test_integration.py @@ -17,7 +17,7 @@ class IntegrationEndpointTest(TestCase): @patch("sentry_sdk.capture_exception") def test_handle_rest_framework_exception( self, mock_capture_exception: MagicMock, mock_stderror_write: MagicMock - ): + ) -> None: exc = APIException("There was a problem!") exc.status_code = 400 # not possible to set in init request = Request(HttpRequest()) @@ -35,7 +35,7 @@ def test_handle_rest_framework_exception( @patch("sentry.integrations.api.bases.integration.capture_exception") def test_handle_exception_503( self, mock_capture_exception: MagicMock, mock_stderror_write: MagicMock - ): + ) -> None: try: raise ApiError("This is an error", code=503) except ApiError as exc: @@ -56,7 +56,7 @@ def test_handle_exception_503( @patch("sentry.api.base.capture_exception") def test_handle_exception_stdlib( self, mock_capture_exception: MagicMock, mock_stderror_write: MagicMock - ): + ) -> None: try: raise ValueError("This is an error") except ValueError as exc: diff --git a/tests/sentry/monitors/logic/test_incident_occurrence.py b/tests/sentry/monitors/logic/test_incident_occurrence.py index e6d841b9b615..b5908ad224a7 100644 --- a/tests/sentry/monitors/logic/test_incident_occurrence.py +++ b/tests/sentry/monitors/logic/test_incident_occurrence.py @@ -31,7 +31,7 @@ class IncidentOccurrenceTestCase(TestCase): - def build_occurrence_test_data(self): + def build_occurrence_test_data(self) -> None: self.monitor = Monitor.objects.create( name="test monitor", organization_id=self.organization.id, @@ -363,9 +363,9 @@ def test_send_incident_occurrence_invalid_owner( @mock.patch("sentry.monitors.logic.incident_occurrence.queue_incident_occurrence") def test_dispatch_incident_occurrence( self, - mock_queue_incident_occurrence, - mock_send_incident_occurrence, - ): + mock_queue_incident_occurrence: mock.MagicMock, + mock_send_incident_occurrence: mock.MagicMock, + ) -> None: monitor = self.create_monitor() monitor_environment = MonitorEnvironment.objects.create( monitor=monitor, diff --git a/tests/sentry/monitors/logic/test_mark_failed.py b/tests/sentry/monitors/logic/test_mark_failed.py index efb5b7c0fbbc..b7418533f732 100644 --- a/tests/sentry/monitors/logic/test_mark_failed.py +++ b/tests/sentry/monitors/logic/test_mark_failed.py @@ -422,8 +422,10 @@ def test_mark_failed_issue_assignment(self) -> None: @mock.patch("sentry.monitors.logic.incidents.dispatch_incident_occurrence") @mock.patch("sentry.monitors.logic.incident_occurrence.resolve_incident_group") def test_mark_failed_fingerprint_after_resolution( - self, mock_resolve_incident_group, mock_dispatch_incident_occurrence - ): + self, + mock_resolve_incident_group: mock.MagicMock, + mock_dispatch_incident_occurrence: mock.MagicMock, + ) -> None: """Test that resolving and recreating an incident maintains the same fingerprint""" monitor = Monitor.objects.create( name="test monitor", diff --git a/tests/sentry/monitors/tasks/test_detect_broken_monitor_envs.py b/tests/sentry/monitors/tasks/test_detect_broken_monitor_envs.py index ee3f6c00ca12..269ffc28958b 100644 --- a/tests/sentry/monitors/tasks/test_detect_broken_monitor_envs.py +++ b/tests/sentry/monitors/tasks/test_detect_broken_monitor_envs.py @@ -45,7 +45,7 @@ def tearDown(self) -> None: super().tearDown() self._run_tasks.__exit__(None, None, None) - def create_monitor_env(self, monitor, environment_id): + def create_monitor_env(self, monitor: Monitor, environment_id: int) -> MonitorEnvironment: return MonitorEnvironment.objects.create( monitor=monitor, environment_id=environment_id, @@ -59,8 +59,12 @@ def generate_cron_monitor_url(self, org_slug: str, project_slug: str, monitor_sl ) def create_monitor_and_env( - self, name="test monitor", organization_id=None, project_id=None, environment_id=None - ): + self, + name: str = "test monitor", + organization_id: int | None = None, + project_id: int | None = None, + environment_id: int | None = None, + ) -> tuple[Monitor, MonitorEnvironment]: if organization_id is None: organization_id = self.organization.id if project_id is None: @@ -93,7 +97,9 @@ def create_monitor_and_env( self.detector_ids[monitor.slug] = detector.id return (monitor, self.create_monitor_env(monitor=monitor, environment_id=environment_id)) - def create_incident_for_monitor_env(self, monitor, monitor_environment): + def create_incident_for_monitor_env( + self, monitor: Monitor, monitor_environment: MonitorEnvironment + ) -> MonitorIncident: first_checkin = MonitorCheckIn.objects.create( monitor_environment=monitor_environment, status=CheckInStatus.ERROR, diff --git a/tests/sentry/notifications/api/endpoints/test_notification_actions_details.py b/tests/sentry/notifications/api/endpoints/test_notification_actions_details.py index d2b85a538ce5..363d7b7a597a 100644 --- a/tests/sentry/notifications/api/endpoints/test_notification_actions_details.py +++ b/tests/sentry/notifications/api/endpoints/test_notification_actions_details.py @@ -1,6 +1,8 @@ +from __future__ import annotations + from collections.abc import Callable, MutableMapping from typing import Any, TypeVar -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, _patch, patch from rest_framework import serializers, status @@ -65,7 +67,9 @@ def setUp(self) -> None: self.login_as(user=self.user) - def mock_msg_schedule_response(self, channel_id, result_name="channel"): + def mock_msg_schedule_response( + self, channel_id: str, result_name: str = "channel" + ) -> _patch[MagicMock]: body = { "ok": True, result_name: channel_id, @@ -73,7 +77,9 @@ def mock_msg_schedule_response(self, channel_id, result_name="channel"): } return mock_slack_response("chat_scheduleMessage", body=body) - def mock_msg_delete_scheduled_response(self, channel_id, result_name="channel"): + def mock_msg_delete_scheduled_response( + self, channel_id: str, result_name: str = "channel" + ) -> _patch[MagicMock]: if channel_id == "channel_not_found": body = {"ok": False, "error": "channel_not_found"} else: diff --git a/tests/sentry/notifications/api/endpoints/test_notification_actions_index.py b/tests/sentry/notifications/api/endpoints/test_notification_actions_index.py index 204540b4ab2b..cc651bb17a36 100644 --- a/tests/sentry/notifications/api/endpoints/test_notification_actions_index.py +++ b/tests/sentry/notifications/api/endpoints/test_notification_actions_index.py @@ -326,7 +326,9 @@ def fire(self, data: Any) -> None: body={"ok": True, "channel": "CABC123", "scheduled_message_id": "Q1298393284"}, ) @mock_slack_response("chat_deleteScheduledMessage", body={"ok": True}) - def test_post_with_slack_validation(self, mock_delete, mock_schedule) -> None: + def test_post_with_slack_validation( + self, mock_delete: MagicMock, mock_schedule: MagicMock + ) -> None: class MockActionRegistration(ActionRegistration): def fire(self, data: Any) -> None: raise NotImplementedError diff --git a/tests/sentry/pipeline/test_pipeline.py b/tests/sentry/pipeline/test_pipeline.py index 716845b9e5d1..e19e7ee51ce8 100644 --- a/tests/sentry/pipeline/test_pipeline.py +++ b/tests/sentry/pipeline/test_pipeline.py @@ -22,9 +22,10 @@ class PipelineStep: - def dispatch(self, request, pipeline): + def dispatch(self, request: HttpRequest, pipeline: "DummyPipeline") -> HttpResponseBase: pipeline.dispatch_count += 1 pipeline.bind_state("some_state", "value") + return HttpResponse() class DummyProvider(PipelineProvider["DummyPipeline"]): @@ -41,7 +42,8 @@ class DummyPipeline(Pipeline[Never, PipelineSessionStore]): pipeline_name = "test_pipeline" - def __init__(self, *args, **kwargs): + # justification: exercises the base pipeline constructor contract. + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.finished = False self.dispatch_count = 0 @@ -56,8 +58,9 @@ def provider(self) -> DummyProvider: def get_pipeline_views(self) -> Sequence[PipelineStep]: return self.provider.get_pipeline_views() - def finish_pipeline(self): + def finish_pipeline(self) -> HttpResponseBase: self.finished = True + return HttpResponse() class ChooseThingView: diff --git a/tests/sentry/uptime/rdap/test_query.py b/tests/sentry/uptime/rdap/test_query.py index ee95019e0c1a..f92053149311 100644 --- a/tests/sentry/uptime/rdap/test_query.py +++ b/tests/sentry/uptime/rdap/test_query.py @@ -1,7 +1,9 @@ +from collections.abc import Iterator from unittest import mock import pytest import responses +from responses import BaseResponse from sentry.uptime.rdap.query import ( RDAP_BOOTSTRAP_REGSITRY, @@ -85,7 +87,7 @@ @pytest.fixture(autouse=True) -def mocked_rdap_registry(): +def mocked_rdap_registry() -> Iterator[BaseResponse]: with responses.mock: yield responses.add( "GET", @@ -94,7 +96,7 @@ def mocked_rdap_registry(): ) -def test_resolve_rdap_bootstrap_registry(mocked_rdap_registry) -> None: +def test_resolve_rdap_bootstrap_registry(mocked_rdap_registry: BaseResponse) -> None: assert resolve_rdap_bootstrap_registry() == SIMPLE_RDAP_BOOTSTRAP_RESPONSE["services"] assert mocked_rdap_registry.call_count == 1 @@ -103,7 +105,7 @@ def test_resolve_rdap_bootstrap_registry(mocked_rdap_registry) -> None: assert mocked_rdap_registry.call_count == 1 -def test_resolve_rdap_provider(mocked_rdap_registry) -> None: +def test_resolve_rdap_provider(mocked_rdap_registry: BaseResponse) -> None: assert resolve_rdap_provider("1.10.20.30") == "https://rdap.example.net/rdap/" assert resolve_rdap_provider("2.10.20.30") == "https://rdap.other-example.net/" assert resolve_rdap_provider("6.0.0.0") is None diff --git a/tests/snuba/tsdb/test_tsdb_backend.py b/tests/snuba/tsdb/test_tsdb_backend.py index 2e4a502e3d5f..03fb99c7565b 100644 --- a/tests/snuba/tsdb/test_tsdb_backend.py +++ b/tests/snuba/tsdb/test_tsdb_backend.py @@ -1,4 +1,5 @@ from datetime import UTC, datetime, timedelta +from typing import Any from unittest.mock import patch from snuba_sdk import Limit @@ -16,12 +17,12 @@ from tests.sentry.issues.test_utils import SearchIssueTestMixin -def timestamp(d): +def timestamp(d: datetime) -> int: t = int(d.timestamp()) return t - (t % 3600) -def has_shape(data, shape): +def has_shape(data: Any, shape: Any) -> bool: # justification: arbitrary JSON shapes. """ Determine if a data object has the provided shape @@ -886,7 +887,14 @@ class AddJitterToSeriesTest(TestCase): def setUp(self) -> None: self.db = SnubaTSDB() - def run_test(self, end, interval, jitter, expected_start, expected_end): + def run_test( + self, + end: datetime, + interval: timedelta, + jitter: int, + expected_start: datetime, + expected_end: datetime, + ) -> None: start = end - interval rollup, rollup_series = self.db.get_optimal_rollup_series(start, end) series = self.db._add_jitter_to_series(rollup_series, start, rollup, jitter) From 163110f0f8f9fb3de5b60ebafbb7f0c6a4761484 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:52:47 -0700 Subject: [PATCH 052/103] ref(typing): graduate lane 15 wildcard packages Annotate the twelve weaklist package families so their wildcard entries can be removed. JSON and OAuth payload boundaries use documented casts; Snuba label-query responses are typed with TypedDicts; Node exposes its actual Edge-keyed UserDict shape. --- src/sentry/debug_files/artifact_bundles.py | 10 +++--- src/sentry/feedback/lib/label_query.py | 19 ++++++++--- src/sentry/feedback/lib/utils.py | 3 +- src/sentry/identity/github/provider.py | 18 +++++----- .../transaction_clusterer/datasource/redis.py | 2 +- .../transaction_clusterer/rule_validator.py | 2 +- .../ingest/transaction_clusterer/tree.py | 2 +- src/sentry/integrations/jira/webhooks/base.py | 2 +- .../integrations/jira/webhooks/installed.py | 2 +- .../jira/webhooks/issue_updated.py | 2 +- .../integrations/jira/webhooks/uninstalled.py | 2 +- .../models/release_threshold/constants.py | 8 ++--- .../monitors/logic/incident_occurrence.py | 12 ++++--- .../monitors/logic/monitor_environment.py | 4 +-- src/sentry/uptime/autodetect/ranking.py | 4 +-- src/sentry/uptime/autodetect/tasks.py | 6 ++-- tests/sentry/consumers/test_dlq.py | 7 ++-- tests/sentry/consumers/test_run.py | 14 ++++++-- .../claude_code/test_integration.py | 33 +++++++++++-------- .../pagerduty/test_integration.py | 9 ++--- tests/sentry/projectoptions/test_basic.py | 15 ++++++--- 21 files changed, 109 insertions(+), 67 deletions(-) diff --git a/src/sentry/debug_files/artifact_bundles.py b/src/sentry/debug_files/artifact_bundles.py index 3d49fe45f267..173aa90e8aaf 100644 --- a/src/sentry/debug_files/artifact_bundles.py +++ b/src/sentry/debug_files/artifact_bundles.py @@ -153,7 +153,7 @@ def index_urls_in_bundle( organization_id: int, artifact_bundle: ArtifactBundle, existing_archive: ArtifactBundleArchive | None, -): +) -> None: skip_debug_id_names = options.get("sourcemaps.artifact-bundles.index-skip-debug-id-names") # We first open up the bundle and extract all the things we want to index from it. @@ -221,7 +221,7 @@ def index_urls_in_bundle( # ===== Renewal of Artifact Bundles ===== -def maybe_renew_artifact_bundles(used_artifact_bundles: dict[int, datetime]): +def maybe_renew_artifact_bundles(used_artifact_bundles: dict[int, datetime]) -> None: # We take a snapshot in time that MUST be consistent across all updates. now = timezone.now() # We compute the threshold used to determine whether we want to renew the specific bundle. @@ -239,7 +239,7 @@ def maybe_renew_artifact_bundles(used_artifact_bundles: dict[int, datetime]): @traces.trace -def renew_artifact_bundle(artifact_bundle_id: int, threshold_date: datetime, now: datetime): +def renew_artifact_bundle(artifact_bundle_id: int, threshold_date: datetime, now: datetime) -> None: metrics.incr("artifact_bundle_renewal.need_renewal") # We want to use a transaction, in order to keep the `date_added` consistent across multiple tables. with atomic_transaction( @@ -359,7 +359,7 @@ def query_artifact_bundles_containing_file( # coarse-grained to fine-grained. artifact_bundles: dict[int, tuple[datetime, str]] = dict() - def update_bundles(bundles: set[tuple[int, datetime]], resolved: str): + def update_bundles(bundles: set[tuple[int, datetime]], resolved: str) -> None: for bundle_id, date_added in bundles: artifact_bundles[bundle_id] = (date_added, resolved) @@ -407,7 +407,7 @@ def get_bundles_indexing_state( total_bundles = 0 indexed_bundles = 0 - filter: dict = { + filter: dict[str, str | int] = { "releaseartifactbundle__release_name": release_name, "releaseartifactbundle__dist_name": dist_name, } diff --git a/src/sentry/feedback/lib/label_query.py b/src/sentry/feedback/lib/label_query.py index ffff63072f50..21f1febcb60b 100644 --- a/src/sentry/feedback/lib/label_query.py +++ b/src/sentry/feedback/lib/label_query.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import TypedDict from snuba_sdk import ( Column, @@ -21,7 +22,17 @@ from sentry.utils.snuba import raw_snql_query -def _get_ai_labels_from_tags(alias: str | None = None): +class AILabelCount(TypedDict): + label: str + count: int + + +class RecentFeedbackWithAILabels(TypedDict): + labels: list[str] + feedback: str + + +def _get_ai_labels_from_tags(alias: str | None = None) -> Function: """ Gets all the AI label values as an array from the tags column. @@ -80,7 +91,7 @@ def query_top_ai_labels_by_feedback_count( start: datetime, end: datetime, limit: int, -): +) -> list[AILabelCount]: """ Query the top `limit` AI-generated labels by feedback count. @@ -140,7 +151,7 @@ def query_recent_feedbacks_with_ai_labels( start: datetime, end: datetime, limit: int, -): +) -> list[RecentFeedbackWithAILabels]: """ Query the most recent `limit` feedbacks, along with their AI labels. Ensures feedbacks have at least one AI label. @@ -246,7 +257,7 @@ def query_label_group_counts( start: datetime, end: datetime, labels_groups: list[list[str]], -): +) -> list[int]: """ Query how many feedbacks are in each of the given `labels_groups`. diff --git a/src/sentry/feedback/lib/utils.py b/src/sentry/feedback/lib/utils.py index 8d3ce6258bcf..3edaf3a0d01a 100644 --- a/src/sentry/feedback/lib/utils.py +++ b/src/sentry/feedback/lib/utils.py @@ -3,6 +3,7 @@ from enum import Enum from sentry import options +from sentry.models.organization import Organization UNREAL_FEEDBACK_UNATTENDED_MESSAGE = "Sent in the unattended mode" @@ -36,5 +37,5 @@ def old_feedback_category_values(cls) -> set[str]: } -def is_in_feedback_denylist(organization): +def is_in_feedback_denylist(organization: Organization) -> bool: return organization.slug in options.get("feedback.organizations.slug-denylist") diff --git a/src/sentry/identity/github/provider.py b/src/sentry/identity/github/provider.py index b3e896ee3b55..347cd72547b7 100644 --- a/src/sentry/identity/github/provider.py +++ b/src/sentry/identity/github/provider.py @@ -1,5 +1,6 @@ import logging -from typing import Any +from collections.abc import Mapping +from typing import Any, cast from django.core.exceptions import PermissionDenied from requests.exceptions import RequestException @@ -18,7 +19,7 @@ logger = logging.getLogger(__name__) -def get_user_info(access_token): +def get_user_info(access_token: str) -> dict[str, object]: from sentry.integrations.github.constants import GITHUB_API_ACCEPT_HEADER with http.build_session() as session: @@ -80,15 +81,16 @@ class GitHubIdentityProvider(OAuth2Provider): oauth_scopes = () - def get_oauth_client_id(self): + def get_oauth_client_id(self) -> str: return options.get("github-app.client-id") - def get_oauth_client_secret(self): + def get_oauth_client_secret(self) -> str: return options.get("github-app.client-secret") - def build_identity(self, data): - data = data["data"] - access_token = data.get("access_token") + def build_identity(self, data: Mapping[str, object]) -> Mapping[str, object]: + # justification: provider OAuth responses are decoded JSON. + identity_data = cast(Mapping[str, object], data["data"]) + access_token = cast(str | None, identity_data.get("access_token")) if not access_token: raise PermissionDenied() user = get_user_info(access_token) @@ -102,7 +104,7 @@ def build_identity(self, data): "name": user["name"], "company": user["company"], "scopes": [], # GitHub apps do not have user scopes - "data": self.get_oauth_data(data), + "data": self.get_oauth_data(identity_data), } def post_link_identity(self, identity: dict[str, Any], user_id: int) -> None: diff --git a/src/sentry/ingest/transaction_clusterer/datasource/redis.py b/src/sentry/ingest/transaction_clusterer/datasource/redis.py index 40ad5fb4c247..a69bf95e1591 100644 --- a/src/sentry/ingest/transaction_clusterer/datasource/redis.py +++ b/src/sentry/ingest/transaction_clusterer/datasource/redis.py @@ -153,7 +153,7 @@ def _bump_rule_lifetime(project: Project, event_data: Mapping[str, Any]) -> None _bump_rule_lifetime_inner(project, applied_rules) -def _bump_rule_lifetime_inner(project: Project, applied_rules: Sequence): +def _bump_rule_lifetime_inner(project: Project, applied_rules: Sequence[Sequence[str]]) -> None: from sentry.ingest.transaction_clusterer import rules as clusterer_rules if not applied_rules: diff --git a/src/sentry/ingest/transaction_clusterer/rule_validator.py b/src/sentry/ingest/transaction_clusterer/rule_validator.py index dc4c61f66876..3436cf36c972 100644 --- a/src/sentry/ingest/transaction_clusterer/rule_validator.py +++ b/src/sentry/ingest/transaction_clusterer/rule_validator.py @@ -30,7 +30,7 @@ def _normalize_rule(self, rule: ReplacementRule) -> ReplacementRule: def _is_all_stars(self) -> bool: return self._is_string_all_stars(self._rule) - def _is_string_all_stars(self, string) -> bool: + def _is_string_all_stars(self, string: str) -> bool: return set(string) <= self._char_domain def _is_schema_and_all_stars(self) -> bool: diff --git a/src/sentry/ingest/transaction_clusterer/tree.py b/src/sentry/ingest/transaction_clusterer/tree.py index 09a05b22de33..0044322937af 100644 --- a/src/sentry/ingest/transaction_clusterer/tree.py +++ b/src/sentry/ingest/transaction_clusterer/tree.py @@ -131,7 +131,7 @@ def _build_rule(path: list["Edge"]) -> ReplacementRule: Edge: TypeAlias = Union[str, Merged] -class Node(UserDict): +class Node(UserDict[Edge, "Node"]): """Keys in this dict are names of the children""" def paths(self, ancestors: list[Edge] | None = None) -> Iterable[list[Edge]]: diff --git a/src/sentry/integrations/jira/webhooks/base.py b/src/sentry/integrations/jira/webhooks/base.py index b9689c5465a9..812302013983 100644 --- a/src/sentry/integrations/jira/webhooks/base.py +++ b/src/sentry/integrations/jira/webhooks/base.py @@ -37,7 +37,7 @@ class JiraWebhookBase(Endpoint, abc.ABC): provider = IntegrationProviderSlug.JIRA.value @csrf_exempt - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponseBase: return super().dispatch(request, *args, **kwargs) def handle_exception_with_details( diff --git a/src/sentry/integrations/jira/webhooks/installed.py b/src/sentry/integrations/jira/webhooks/installed.py index 105ea199112b..e6dbdf4b669e 100644 --- a/src/sentry/integrations/jira/webhooks/installed.py +++ b/src/sentry/integrations/jira/webhooks/installed.py @@ -39,7 +39,7 @@ class JiraSentryInstalledWebhook(JiraWebhookBase): Webhook hit by Jira whenever someone installs the Sentry integration in their Jira instance. """ - def post(self, request: Request, *args, **kwargs) -> Response: + def post(self, request: Request, *args: object, **kwargs: object) -> Response: with IntegrationPipelineViewEvent( interaction_type=IntegrationPipelineViewType.VERIFY_INSTALLATION, domain=IntegrationDomain.PROJECT_MANAGEMENT, diff --git a/src/sentry/integrations/jira/webhooks/issue_updated.py b/src/sentry/integrations/jira/webhooks/issue_updated.py index 9473751c0f75..1e51c6f67583 100644 --- a/src/sentry/integrations/jira/webhooks/issue_updated.py +++ b/src/sentry/integrations/jira/webhooks/issue_updated.py @@ -68,7 +68,7 @@ def handle_exception_with_details( return super().handle_exception_with_details(request, exc, handler_context, scope) - def post(self, request: Request, *args, **kwargs) -> Response: + def post(self, request: Request, *args: object, **kwargs: object) -> Response: token = self.get_token(request) rpc_integration = get_integration_from_jwt( token=token, diff --git a/src/sentry/integrations/jira/webhooks/uninstalled.py b/src/sentry/integrations/jira/webhooks/uninstalled.py index c1387154d945..a2d284cb927d 100644 --- a/src/sentry/integrations/jira/webhooks/uninstalled.py +++ b/src/sentry/integrations/jira/webhooks/uninstalled.py @@ -23,7 +23,7 @@ class JiraSentryUninstalledWebhook(JiraWebhookBase): Webhook hit by Jira whenever someone uninstalls the Sentry integration from their Jira instance. """ - def post(self, request: Request, *args, **kwargs) -> Response: + def post(self, request: Request, *args: object, **kwargs: object) -> Response: token = self.get_token(request) rpc_integration = get_integration_from_jwt( token=token, diff --git a/src/sentry/models/release_threshold/constants.py b/src/sentry/models/release_threshold/constants.py index e231687e1ed4..f9cb8402b7f9 100644 --- a/src/sentry/models/release_threshold/constants.py +++ b/src/sentry/models/release_threshold/constants.py @@ -31,7 +31,7 @@ class ReleaseThresholdType: CRASH_FREE_USER_RATE_STR: Final = "crash_free_user_rate" @classmethod - def as_choices(cls): + def as_choices(cls) -> tuple[tuple[str, int], ...]: return ( (cls.TOTAL_ERROR_COUNT_STR, cls.TOTAL_ERROR_COUNT), (cls.NEW_ISSUE_COUNT_STR, cls.NEW_ISSUE_COUNT), @@ -43,7 +43,7 @@ def as_choices(cls): ) @classmethod - def as_str_choices(cls): + def as_str_choices(cls) -> tuple[tuple[str, str], ...]: return ( (cls.TOTAL_ERROR_COUNT_STR, cls.TOTAL_ERROR_COUNT_STR), (cls.NEW_ISSUE_COUNT_STR, cls.NEW_ISSUE_COUNT_STR), @@ -63,14 +63,14 @@ class TriggerType: UNDER_STR: Final = "under" @classmethod - def as_choices(cls): # choices for model column + def as_choices(cls) -> tuple[tuple[str, int], ...]: # choices for model column return ( (cls.OVER_STR, cls.OVER), (cls.UNDER_STR, cls.UNDER), ) @classmethod - def as_str_choices(cls): # choices for serializer + def as_str_choices(cls) -> tuple[tuple[str, str], ...]: # choices for serializer return ( (cls.OVER_STR, cls.OVER_STR), (cls.UNDER_STR, cls.UNDER_STR), diff --git a/src/sentry/monitors/logic/incident_occurrence.py b/src/sentry/monitors/logic/incident_occurrence.py index 71a1e7dfb9b3..0f1e02474aa2 100644 --- a/src/sentry/monitors/logic/incident_occurrence.py +++ b/src/sentry/monitors/logic/incident_occurrence.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING from arroyo import Topic as ArroyoTopic -from arroyo.backends.kafka import KafkaPayload +from arroyo.backends.kafka import KafkaPayload, KafkaProducer from django.utils.text import get_text_list from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -43,7 +43,7 @@ ) -def _get_producer(): +def _get_producer() -> KafkaProducer: return get_arroyo_producer( name="sentry.monitors.logic.incident_occurrence", topic=Topic.MONITORS_INCIDENT_OCCURRENCES, @@ -242,7 +242,9 @@ def send_incident_occurrence( } -def get_failure_reason(failed_checkins: Sequence[MonitorCheckIn]): +def get_failure_reason( + failed_checkins: Sequence[MonitorCheckIn], +) -> str | _StrPromise: """ Builds a human readable string from a list of failed check-ins. @@ -271,7 +273,9 @@ def get_failure_reason(failed_checkins: Sequence[MonitorCheckIn]): return _("%(problem_checkins)s check-ins detected") % {"problem_checkins": human_status} -def get_monitor_environment_context(monitor_environment: MonitorEnvironment): +def get_monitor_environment_context( + monitor_environment: MonitorEnvironment, +) -> dict[str, object]: config = monitor_environment.monitor.config.copy() if "schedule_type" in config: config["schedule_type"] = monitor_environment.monitor.get_schedule_type_display() diff --git a/src/sentry/monitors/logic/monitor_environment.py b/src/sentry/monitors/logic/monitor_environment.py index 1156e5050893..f0c6652a7c0f 100644 --- a/src/sentry/monitors/logic/monitor_environment.py +++ b/src/sentry/monitors/logic/monitor_environment.py @@ -5,7 +5,7 @@ def monitor_has_newer_status_affecting_checkins( monitor_env: MonitorEnvironment, timestamp: datetime -): +) -> bool: return MonitorCheckIn.objects.filter( monitor_environment=monitor_env, date_added__gt=timestamp, @@ -18,7 +18,7 @@ def update_monitor_environment( last_checkin: datetime | None, expected_ts: datetime, monitor_status: int | None = None, -): +) -> None: # Compute the next check-in time from our reference time next_checkin = monitor_env.monitor.get_next_expected_checkin(expected_ts) next_checkin_latest = monitor_env.monitor.get_next_expected_checkin_latest(expected_ts) diff --git a/src/sentry/uptime/autodetect/ranking.py b/src/sentry/uptime/autodetect/ranking.py index 0ba746643923..862e7d89bfe7 100644 --- a/src/sentry/uptime/autodetect/ranking.py +++ b/src/sentry/uptime/autodetect/ranking.py @@ -34,7 +34,7 @@ KEY_EXPIRY = ORGANIZATION_FLUSH_FREQUENCY * 2 -def add_base_url_to_rank(project: Project, base_url: str): +def add_base_url_to_rank(project: Project, base_url: str) -> None: """ Takes a project and valid base url and stores ranking information about it in Redis. @@ -102,7 +102,7 @@ def delete_candidate_projects_for_org(org: Organization) -> None: cluster.delete(key) -def get_candidate_urls_for_project(project: Project, limit=5) -> list[tuple[str, int]]: +def get_candidate_urls_for_project(project: Project, limit: int = 5) -> list[tuple[str, int]]: """ Gets all the candidate urls for a project. Returns a tuple of (url, times_url_seen). Urls are sorted by `times_url_seen` desc. diff --git a/src/sentry/uptime/autodetect/tasks.py b/src/sentry/uptime/autodetect/tasks.py index 05f089e24d5b..ed808ad776fc 100644 --- a/src/sentry/uptime/autodetect/tasks.py +++ b/src/sentry/uptime/autodetect/tasks.py @@ -57,7 +57,7 @@ namespace=uptime_tasks, processing_deadline_duration=60, ) -def schedule_autodetections(): +def schedule_autodetections() -> None: """ Runs regularly and fires off a task for each autodetection bucket that needs to be run since this task last ran. @@ -97,7 +97,7 @@ def schedule_autodetections(): name="sentry.uptime.detectors.tasks.process_detection_bucket", namespace=uptime_tasks, ) -def process_autodetection_bucket(bucket: str): +def process_autodetection_bucket(bucket: str) -> None: """ Schedules url autodetection for all projects in this time bucket that saw promising urls. """ @@ -114,7 +114,7 @@ def process_autodetection_bucket(bucket: str): namespace=uptime_tasks, processing_deadline_duration=20, ) -def process_organization_url_ranking(organization_id: int): +def process_organization_url_ranking(organization_id: int) -> None: org = Organization.objects.get_from_cache(id=organization_id) logger.info( "uptime.process_organization", diff --git a/tests/sentry/consumers/test_dlq.py b/tests/sentry/consumers/test_dlq.py index 8cbcd885eb9c..dd3b68dd56e5 100644 --- a/tests/sentry/consumers/test_dlq.py +++ b/tests/sentry/consumers/test_dlq.py @@ -10,10 +10,13 @@ from arroyo.types import BrokerValue, Message, Partition, Topic from sentry.consumers.dlq import DlqStaleMessagesStrategyFactoryWrapper +from sentry.testutils.factories import Factories from sentry.testutils.pytest.fixtures import django_db_all -def make_message(payload: bytes, partition: Partition, offset: int, timestamp: datetime) -> Message: +def make_message( + payload: bytes, partition: Partition, offset: int, timestamp: datetime +) -> Message[KafkaPayload]: return Message( BrokerValue( KafkaPayload(None, payload, []), @@ -26,7 +29,7 @@ def make_message(payload: bytes, partition: Partition, offset: int, timestamp: d @pytest.mark.parametrize("stale_threshold_sec", [300]) @django_db_all -def test_dlq_stale_messages_timestamps(factories, stale_threshold_sec) -> None: +def test_dlq_stale_messages_timestamps(factories: Factories, stale_threshold_sec: int) -> None: organization = factories.create_organization() project = factories.create_project(organization=organization) diff --git a/tests/sentry/consumers/test_run.py b/tests/sentry/consumers/test_run.py index 1713e484dae4..1124c11702e3 100644 --- a/tests/sentry/consumers/test_run.py +++ b/tests/sentry/consumers/test_run.py @@ -1,7 +1,9 @@ +from collections.abc import Mapping from unittest.mock import patch import pytest from arroyo.processing.strategies.abstract import ProcessingStrategyFactory +from pytest_django.fixtures import SettingsWrapper from sentry import consumers from sentry.conf.types.kafka_definition import ConsumerDefinition, Topic @@ -10,7 +12,9 @@ @pytest.mark.parametrize("consumer_def", list(consumers.KAFKA_CONSUMERS.items())) -def test_all_importable(consumer_def, settings) -> None: +def test_all_importable( + consumer_def: tuple[str, ConsumerDefinition], settings: SettingsWrapper +) -> None: name: str defn: ConsumerDefinition name, defn = consumer_def @@ -23,7 +27,7 @@ def test_all_importable(consumer_def, settings) -> None: @pytest.mark.parametrize("consumer_def", list(consumers.KAFKA_CONSUMERS.items())) -def test_dlq(consumer_def) -> None: +def test_dlq(consumer_def: tuple[str, ConsumerDefinition]) -> None: post_process_forwarders = [ "post-process-forwarder-errors", "post-process-forwarder-transactions", @@ -49,7 +53,11 @@ def test_dlq(consumer_def) -> None: def test_commit_log_consumer_config_keyed_by_own_topic() -> None: topics_seen: list[Topic | None] = [] - def fake_cluster_options(cluster_name, override_params=None, topic=None): + def fake_cluster_options( + cluster_name: str, + override_params: Mapping[str, object] | None = None, + topic: Topic | None = None, + ) -> dict[str, str]: topics_seen.append(topic) return {"bootstrap.servers": "127.0.0.1:9092"} diff --git a/tests/sentry/integrations/claude_code/test_integration.py b/tests/sentry/integrations/claude_code/test_integration.py index 7acdc6d28b0a..bf2d5ff67a2b 100644 --- a/tests/sentry/integrations/claude_code/test_integration.py +++ b/tests/sentry/integrations/claude_code/test_integration.py @@ -6,7 +6,7 @@ import hashlib from collections.abc import Mapping -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from unittest.mock import MagicMock, patch import pytest @@ -26,10 +26,17 @@ from sentry.testutils.cases import APITestCase, IntegrationTestCase from sentry.testutils.silo import assume_test_silo_mode, control_silo_test +if TYPE_CHECKING: + from sentry.integrations.coding_agent.models import CodingAgentLaunchRequest + MOCK_GET_CLIENT_CLASS = "sentry.integrations.claude_code.integration._get_client_class" -def _mock_client_class(validate_return=True, validate_side_effect=None, **client_attrs): +def _mock_client_class( + validate_return: bool = True, + validate_side_effect: object = None, + **client_attrs: object, +) -> tuple[MagicMock, MagicMock]: """Create a mock client class whose instances have validate_api_key and optional attributes.""" mock_client = MagicMock() mock_client.model = "claude-opus-4-6" @@ -47,8 +54,8 @@ def _mock_client_class(validate_return=True, validate_side_effect=None, **client class ClaudeCodeIntegrationTest(IntegrationTestCase): provider = ClaudeCodeAgentIntegrationProvider - def _make_metadata(self, **overrides): - defaults = { + def _make_metadata(self, **overrides: object) -> dict[str, object]: + defaults: dict[str, object] = { "api_key": "sk-ant-test-api-key-123", "domain_name": "anthropic.com", } @@ -64,7 +71,7 @@ def _expected_external_id(self) -> str: digest = hashlib.sha256(f"{PROVIDER_KEY}:{self.organization.id}".encode()).hexdigest() return digest[:32] - def _create_installation(self, **metadata_overrides) -> ClaudeCodeAgentIntegration: + def _create_installation(self, **metadata_overrides: object) -> ClaudeCodeAgentIntegration: """Helper to create an integration and return its installation.""" integration = self.create_integration( organization=self.organization, @@ -459,7 +466,7 @@ def test_uninstall_concurrent_vault_write_during_archive_is_preserved(self) -> N ) mock_cls, mock_client = _mock_client_class() - def archive_with_concurrent_write(vault_id): + def archive_with_concurrent_write(vault_id: str) -> None: with assume_test_silo_mode(SiloMode.CONTROL): fresh = Integration.objects.get(id=installation.model.id) fresh.metadata["installation_vault_ids"]["99"] = "vault_new" @@ -478,13 +485,13 @@ def archive_with_concurrent_write(vault_id): def _setup_launch( self, - environment_id=None, - client_environment_id=None, - agent_id=None, - agent_version=None, - client_agent_id=None, - client_agent_version=None, - ): + environment_id: str | None = None, + client_environment_id: str | None = None, + agent_id: str | None = None, + agent_version: int | None = None, + client_agent_id: str | None = None, + client_agent_version: int | None = None, + ) -> tuple[ClaudeCodeAgentIntegration, MagicMock, MagicMock, CodingAgentLaunchRequest]: """Set up mocks for launch tests. Returns (installation, mock_client, mock_cls, request).""" from sentry.integrations.coding_agent.models import CodingAgentLaunchRequest from sentry.seer.autofix.constants import CodingAgentStatus diff --git a/tests/sentry/integrations/pagerduty/test_integration.py b/tests/sentry/integrations/pagerduty/test_integration.py index 6e108af7d73d..b2eb56c4e1a9 100644 --- a/tests/sentry/integrations/pagerduty/test_integration.py +++ b/tests/sentry/integrations/pagerduty/test_integration.py @@ -11,6 +11,7 @@ from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.pagerduty.utils import add_service, get_services from sentry.integrations.pipeline import IntegrationPipeline +from sentry.models.organization import Organization from sentry.shared_integrations.exceptions import IntegrationError from sentry.testutils.cases import APITestCase, TestCase from sentry.testutils.silo import control_silo_test @@ -128,21 +129,21 @@ def setUp(self) -> None: self.app_id = "app_1" options.set("pagerduty.app-id", self.app_id) - def _get_pipeline_url(self, organization=None) -> str: + def _get_pipeline_url(self, organization: Organization | None = None) -> str: organization = organization or self.organization return reverse( self.endpoint, args=[organization.slug, IntegrationPipeline.pipeline_name], ) - def _initialize_pipeline(self, organization=None) -> Any: + def _initialize_pipeline(self, organization: Organization | None = None) -> Any: return self.client.post( self._get_pipeline_url(organization), data={"action": "initialize", "provider": "pagerduty"}, format="json", ) - def _advance_step(self, data: dict[str, Any], organization=None) -> Any: + def _advance_step(self, data: dict[str, Any], organization: Organization | None = None) -> Any: return self.client.post(self._get_pipeline_url(organization), data=data, format="json") def _make_config( @@ -165,7 +166,7 @@ def _make_config( } return orjson.dumps(config).decode() - def _install(self, organization=None, **config: str) -> Any: + def _install(self, organization: Organization | None = None, **config: str) -> Any: self._initialize_pipeline(organization) return self._advance_step({"config": self._make_config(**config)}, organization) diff --git a/tests/sentry/projectoptions/test_basic.py b/tests/sentry/projectoptions/test_basic.py index a3ed0f78163e..81d9c0e0ede8 100644 --- a/tests/sentry/projectoptions/test_basic.py +++ b/tests/sentry/projectoptions/test_basic.py @@ -1,18 +1,23 @@ +from __future__ import annotations + from unittest import mock from sentry.models.options.project_option import ProjectOption +from sentry.models.project import Project +from sentry.models.team import Team from sentry.projectoptions import default_manager, defaults, get_well_known_default from sentry.projectoptions.manager import WellKnownProjectOption +from sentry.testutils.factories import Factories from sentry.testutils.pytest.fixtures import django_db_all -def latest_epoch(value): +def latest_epoch(value: int) -> mock._patch[int]: return mock.patch.object(defaults, "LATEST_EPOCH", value) @django_db_all @latest_epoch(10) -def test_defaults(default_project) -> None: +def test_defaults(default_project: Project) -> None: default_manager.register( key="__sentry_test:test-option", epoch_defaults={1: "whatever", 20: "new-value", 42: "latest-value"}, @@ -28,7 +33,7 @@ def test_defaults(default_project) -> None: @django_db_all -def freeze_option(factories, default_team): +def freeze_option(factories: Factories, default_team: Team) -> None: with latest_epoch(42): project = factories.create_project(name="Bar", slug="bar", teams=[default_team]) assert project.get_option("sentry:option-epoch", defaults.LATEST_EPOCH) @@ -64,7 +69,7 @@ def test_loader_version_defaults() -> None: @django_db_all -def test_isset_simple(default_project) -> None: +def test_isset_simple(default_project: Project) -> None: default_manager.register("best_dogs", default="all dogs") assert default_project.get_option("best_dogs") == "all dogs" @@ -76,7 +81,7 @@ def test_isset_simple(default_project) -> None: @django_db_all -def test_isset_differentiates_unset_from_set_to_default(default_project) -> None: +def test_isset_differentiates_unset_from_set_to_default(default_project: Project) -> None: default_manager.register("best_dogs", default="all dogs") assert default_project.get_option("best_dogs") == "all dogs" From f9cd3e57e59ba46edd9f98e2c09a512006281cd7 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:54:06 -0700 Subject: [PATCH 053/103] ref(typing): remove lane 15 wildcard entries Integrate the wildcard packages and type the feedback category rows so the count sort stays comparable. --- pyproject.toml | 12 ------------ .../endpoints/organization_feedback_categories.py | 8 +++++++- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5b6634441710..519f644e6378 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -502,21 +502,18 @@ module = [ "sentry.db.models.fields.uuid", "sentry.db.postgres.*", "sentry.db.router", - "sentry.debug_files.*", "sentry.discover.arithmetic", "sentry.discover.endpoints.*", "sentry.discover.translation.*", "sentry.explore.endpoints.*", "sentry.explore.translation.*", "sentry.feedback.endpoints.*", - "sentry.feedback.lib.*", "sentry.feedback.usecases.*", "sentry.flags.endpoints.secrets", "sentry.flags.providers", "sentry.grouping.enhancer.rules", "sentry.grouping.strategies.*", "sentry.http", - "sentry.identity.github.*", "sentry.identity.manager", "sentry.identity.oauth2", "sentry.identity.slack.*", @@ -524,7 +521,6 @@ module = [ "sentry.incidents.endpoints.serializers.*", "sentry.ingest.consumer.*", "sentry.ingest.inbound_filters", - "sentry.ingest.transaction_clusterer.*", "sentry.integrations.api.endpoints.data_forwarding_details", "sentry.integrations.api.endpoints.data_forwarding_index", "sentry.integrations.api.endpoints.doc_integrations_index", @@ -547,7 +543,6 @@ module = [ "sentry.integrations.jira.client", "sentry.integrations.jira.integration", "sentry.integrations.jira.views.*", - "sentry.integrations.jira.webhooks.*", "sentry.integrations.jira_server.client", "sentry.integrations.jira_server.integration", "sentry.integrations.jira_server.webhooks", @@ -600,12 +595,10 @@ module = [ "sentry.models.projectcodeowners", "sentry.models.recentsearch", "sentry.models.release", - "sentry.models.release_threshold.*", "sentry.models.releases.*", "sentry.models.repository", "sentry.models.team", "sentry.monitors.endpoints.*", - "sentry.monitors.logic.*", "sentry.monitors.models", "sentry.monitors.processing_errors.*", "sentry.monitors.rate_limit", @@ -706,7 +699,6 @@ module = [ "sentry.testutils.silo", "sentry.toolbar.views.*", "sentry.tsdb.*", - "sentry.uptime.autodetect.*", "sentry.uptime.consumers.*", "sentry.uptime.endpoints.*", "sentry.uptime.subscriptions.*", @@ -899,7 +891,6 @@ module = [ "tests.sentry.billing.*", "tests.sentry.buffer.*", "tests.sentry.cache.*", - "tests.sentry.consumers.*", "tests.sentry.core.*", "tests.sentry.dashboards.*", "tests.sentry.db", @@ -943,7 +934,6 @@ module = [ "tests.sentry.integrations", "tests.sentry.integrations.api.endpoints.*", "tests.sentry.integrations.bitbucket.*", - "tests.sentry.integrations.claude_code.*", "tests.sentry.integrations.cursor.*", "tests.sentry.integrations.data_forwarding.*", "tests.sentry.integrations.github.*", @@ -966,7 +956,6 @@ module = [ "tests.sentry.integrations.jira.test_webhooks", "tests.sentry.integrations.jira_server.*", "tests.sentry.integrations.opsgenie.*", - "tests.sentry.integrations.pagerduty.*", "tests.sentry.integrations.perforce.*", "tests.sentry.integrations.slack", "tests.sentry.integrations.slack.notifications.*", @@ -1093,7 +1082,6 @@ module = [ "tests.sentry.profiles.test_java", "tests.sentry.profiles.test_task", "tests.sentry.profiles.test_utils", - "tests.sentry.projectoptions.*", "tests.sentry.receivers", "tests.sentry.receivers.test_analytics", "tests.sentry.receivers.test_core", diff --git a/src/sentry/feedback/endpoints/organization_feedback_categories.py b/src/sentry/feedback/endpoints/organization_feedback_categories.py index 13cab166f826..6a4b360e2786 100644 --- a/src/sentry/feedback/endpoints/organization_feedback_categories.py +++ b/src/sentry/feedback/endpoints/organization_feedback_categories.py @@ -62,6 +62,12 @@ class FeedbackLabelGroup(TypedDict): associatedLabels: list[str] +class FeedbackCategory(TypedDict): + primaryLabel: str + associatedLabels: list[str] + feedbackCount: int + + @cell_silo_endpoint class OrganizationFeedbackCategoriesEndpoint(OrganizationEndpoint): owner = ApiOwner.FEEDBACK @@ -289,7 +295,7 @@ def get(self, request: Request, organization: Organization) -> Response: labels_groups=label_groups_lists, ) - categories = [] + categories: list[FeedbackCategory] = [] for i, list_group in enumerate(label_groups_lists): primaryLabel = list_group[0] associatedLabels = list_group[1:] From 14e2488b93ade7923608d3234a1de7d50217e426 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:51:12 -0700 Subject: [PATCH 054/103] ref(typing): graduate lane 11 weaklist modules Annotate ten weaklisted modules so they pass strict mypy without their `disallow_untyped_defs` exemption. - options.manager: `get` stays dynamically typed (TAny) because option values are untyped by design; `get` is consumed in 800+ places that would otherwise need a repo-wide contract change. `set`, `delete`, `register`, and `_is_none_type` are precisely typed. - utils.types: parameterize the DictType/SequenceType singletons and add a catch-all `type_from_value` overload for `object` inputs. - api.serializers.snuba: add a `TimeSeriesLookup` protocol for the getsentry-provided lookup and type the timeseries helpers. `serialize` returns `dict[str, Any]` to preserve the existing dynamic payload contract; the seer anomaly formatting call now guards the optional `data` key. - api.serializers.models.project: pin `dataset` to `ModuleType`, add the `ProjectWithOrganizationResponse` TypedDict, and annotate the stats and option helpers. - web.frontend.generic, api.endpoints.organization_unsubscribe, models.eventerror, db.models.fields.array, social_auth.fields, tasks.statistical_detectors: annotate parameters, returns, and field generics; `ErrorData` documents the arbitrary SDK-supplied payload. Skipped: sentry.http (its URL parameter is optional at every call site, and typing `safe_urlread` as bytes exposes a bytes/str mismatch in the OAuth callback), sentry.issues.issue_search (ValueConverter's stale `User` parameter cannot be widened without editing ~20 converters and the search helpers), sentry.snuba.functions (the empty-result branch of `format_top_events_timeseries_results` returns a bare SnubaTSResult while every caller treats the result as a dict). --- .../api/endpoints/organization_unsubscribe.py | 10 +-- src/sentry/api/serializers/models/project.py | 31 +++++-- src/sentry/api/serializers/snuba.py | 85 ++++++++++++++----- src/sentry/db/models/fields/array.py | 25 ++++-- src/sentry/models/eventerror.py | 14 +-- src/sentry/options/manager.py | 25 ++++-- src/sentry/seer/anomaly_detection/utils.py | 2 +- src/sentry/tasks/statistical_detectors.py | 25 ++++-- src/sentry/utils/types.py | 16 ++-- src/sentry/web/frontend/generic.py | 19 +++-- src/social_auth/fields.py | 12 +-- 11 files changed, 186 insertions(+), 78 deletions(-) diff --git a/src/sentry/api/endpoints/organization_unsubscribe.py b/src/sentry/api/endpoints/organization_unsubscribe.py index 17e3031c6082..1877fed88ed1 100644 --- a/src/sentry/api/endpoints/organization_unsubscribe.py +++ b/src/sentry/api/endpoints/organization_unsubscribe.py @@ -41,14 +41,14 @@ class OrganizationUnsubscribeBase(Endpoint, Generic[T]): def fetch_instance(self, request: Request, organization_id_or_slug: int | str, id: int) -> T: raise NotImplementedError() - def unsubscribe(self, request: Request, instance: T): + def unsubscribe(self, request: Request, instance: T) -> None: raise NotImplementedError() def add_instance_data(self, data: dict[str, Any], instance: T) -> dict[str, Any]: return data def get( - self, request: Request, organization_id_or_slug: int | str, id: int, **kwargs + self, request: Request, organization_id_or_slug: int | str, id: int, **kwargs: object ) -> Response: if not auth.is_user_signed_request(request): raise NotFound() @@ -69,7 +69,7 @@ def get( return Response(self.add_instance_data(data, instance), 200) def post( - self, request: Request, organization_id_or_slug: int | str, id: int, **kwargs + self, request: Request, organization_id_or_slug: int | str, id: int, **kwargs: object ) -> Response: if not request.user_from_signed_request: raise NotFound() @@ -108,7 +108,7 @@ def add_instance_data(self, data: dict[str, Any], instance: Project) -> dict[str return data - def unsubscribe(self, request: Request, instance: Project): + def unsubscribe(self, request: Request, instance: Project) -> None: notifications_service.update_notification_options( actor=Actor(id=request.user.pk, actor_type=ActorType.USER), type=NotificationSettingEnum.ISSUE_ALERTS, @@ -142,7 +142,7 @@ def fetch_instance( raise NotFound() return issue - def unsubscribe(self, request: Request, instance: Group): + def unsubscribe(self, request: Request, instance: Group) -> None: GroupSubscription.objects.update_or_create( group=instance, project_id=instance.project_id, diff --git a/src/sentry/api/serializers/models/project.py b/src/sentry/api/serializers/models/project.py index e9e285bb1a01..a6649938828d 100644 --- a/src/sentry/api/serializers/models/project.py +++ b/src/sentry/api/serializers/models/project.py @@ -3,6 +3,7 @@ from collections import defaultdict from collections.abc import Collection, Iterable, Mapping, MutableMapping, Sequence from datetime import datetime, timedelta +from types import ModuleType from typing import TYPE_CHECKING, Any, Final, NotRequired, Protocol, TypedDict, cast import orjson @@ -12,6 +13,7 @@ from django.db.models import prefetch_related_objects from django.utils import timezone from sentry_sdk import traces +from sentry_sdk.traces import StreamedSpan from sentry import features, options, projectoptions, quotas, release_health, roles from sentry.api.serializers import Serializer, register, serialize @@ -338,6 +340,8 @@ class ProjectSerializer(Serializer): such as "show all projects for this organization", and its attributes be kept to a minimum. """ + dataset: ModuleType + def __init__( self, environment_id: str | None = None, @@ -375,7 +379,7 @@ def _collapse(self, key: str) -> bool: def get_attrs( self, item_list: Sequence[Project], user: User | RpcUser | AnonymousUser, **kwargs: object ) -> dict[Project, dict[str, object]]: - def measure_span(op_tag): + def measure_span(op_tag: str) -> StreamedSpan: span = traces.start_span( name=f"serialize.get_attrs.project.{op_tag}", attributes={ @@ -450,7 +454,9 @@ def measure_span(op_tag): serialized["options"] = options[project.id] return result - def get_stats(self, projects, query): + def get_stats( + self, projects: Sequence[Project], query: str + ) -> dict[int, list[tuple[int, int]]]: assert self.stats_period is not None # we need to compute stats at 1d (1h resolution), and 14d segments, interval = STATS_PERIOD_CHOICES[self.stats_period] @@ -666,6 +672,10 @@ def serialize( return context +class ProjectWithOrganizationResponse(ProjectSerializerResponse): + organization: OrganizationSummarySerializerResponse + + class ProjectWithOrganizationSerializer(ProjectSerializer): def get_attrs( self, item_list: Sequence[Project], user: User | RpcUser | AnonymousUser, **kwargs: object @@ -677,9 +687,16 @@ def get_attrs( attrs[item]["organization"] = orgs[str(item.organization_id)] return attrs - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, + obj: Project, + attrs: Mapping[str, object], + user: User | RpcUser | AnonymousUser, + **kwargs: object, + ) -> ProjectWithOrganizationResponse: base = super().serialize(obj, attrs, user) - return {**base, "organization": attrs["organization"]} + organization = cast("OrganizationSummarySerializerResponse", attrs["organization"]) + return {**base, "organization": organization} class TeamResponseDict(TypedDict): @@ -765,7 +782,8 @@ class _DeployDict(TypedDict): class ProjectSummarySerializer(ProjectWithTeamSerializer): - def __init__(self, access: Access | None = None, **kwargs) -> None: + # justification: forwards the base serializer keyword options. + def __init__(self, access: Access | None = None, **kwargs: Any) -> None: self.access = access super().__init__(**kwargs) @@ -1335,7 +1353,8 @@ def format_options(self, attrs: Mapping[str, Any]) -> dict[str, object]: ), } - def get_value_with_default(self, attrs, key): + # justification: project option values are dynamically typed per option. + def get_value_with_default(self, attrs: Mapping[str, Any], key: str) -> Any: value = attrs["options"].get(key) if value is not None: return value diff --git a/src/sentry/api/serializers/snuba.py b/src/sentry/api/serializers/snuba.py index c6de3478c177..d186420149f4 100644 --- a/src/sentry/api/serializers/snuba.py +++ b/src/sentry/api/serializers/snuba.py @@ -1,5 +1,17 @@ +from __future__ import annotations + import itertools -from typing import Any, NotRequired, TypedDict +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import TYPE_CHECKING, Any, NotRequired, Protocol, TypedDict, cast + +if TYPE_CHECKING: + from django.contrib.auth.models import AnonymousUser + + from sentry.models.organization import Organization + from sentry.users.models.user import User + from sentry.users.services.user import RpcUser + from sentry.utils.snuba import SnubaTSResult class StatsTimeSeriesResult(TypedDict): @@ -26,17 +38,36 @@ class StatsTimeSeriesResult(TypedDict): end: NotRequired[int] -def value_from_row(row, tagkey): +class TimeSeriesLookup(Protocol): + columns: Sequence[str] + name: str + + def serializer( + self, + organization: Organization | None, + item_list: list[tuple[object, ...]], + user: User | RpcUser | AnonymousUser | None, + ) -> Mapping[tuple[object, ...], object]: ... + + +def value_from_row(row: Mapping[str, object], tagkey: Sequence[str]) -> tuple[object, ...]: return tuple(row[k] for k in tagkey) -def zerofill(data, start, end, rollup, allow_partial_buckets=False, fill_default=None): +def zerofill( + data: Sequence[tuple[float, object]], + start: datetime, + end: datetime, + rollup: int, + allow_partial_buckets: bool = False, + fill_default: object = None, +) -> list[tuple[float, object]]: if fill_default is None: fill_default = [] rv = [] - end = int(end.timestamp()) + end_seconds = int(end.timestamp()) rollup_start = (int(start.timestamp()) // rollup) * rollup - rollup_end = (end // rollup) * rollup + rollup_end = (end_seconds // rollup) * rollup # Fudge the end value when we're only getting a single window. # This ensure that we get both values for a single large window that @@ -61,13 +92,13 @@ def zerofill(data, start, end, rollup, allow_partial_buckets=False, fill_default # Add any remaining rows that are not aligned to the rollup and are lower than the # end date. if i < len(data): - end_timestamp = end if allow_partial_buckets else rollup_end + end_timestamp = end_seconds if allow_partial_buckets else rollup_end rv.extend(row for row in data[i:] if row[0] < end_timestamp) return rv -def calculate_time_frame(start, end, rollup): +def calculate_time_frame(start: datetime, end: datetime, rollup: int) -> dict[str, int]: rollup_start = (int(start.timestamp()) // rollup) * rollup rollup_end = (int(end.timestamp()) // rollup) * rollup if rollup_end - rollup_start == rollup: @@ -80,12 +111,19 @@ class SnubaTSResultSerializer: Serializer for time-series Snuba data. """ - def __init__(self, organization, lookup, user): + def __init__( + self, + organization: Organization | None, + lookup: TimeSeriesLookup | None, + user: User | RpcUser | AnonymousUser | None, + ) -> None: self.organization = organization self.lookup = lookup self.user = user - def get_attrs(self, item_list): + def get_attrs( + self, item_list: list[tuple[object, ...]] + ) -> Mapping[tuple[object, ...], object] | list[tuple[object, ...]]: if self.lookup is None: return item_list @@ -93,22 +131,25 @@ def get_attrs(self, item_list): def serialize( self, - result, - column="count", - order=None, - allow_partial_buckets=False, - zerofill_results=True, - extra_columns=None, - confidence_column="count", - ): + result: SnubaTSResult, + column: str = "count", + order: int | None = None, + allow_partial_buckets: bool = False, + zerofill_results: bool = True, + extra_columns: Sequence[str] | None = None, + confidence_column: str = "count", + ) -> dict[str, Any]: # justification: payload shape varies per axis/column data = [ (key, list(group)) for key, group in itertools.groupby(result.data["data"], key=lambda r: r["time"]) ] - attrs = {} + attrs: Mapping[tuple[object, ...], object] = {} if self.lookup: - attrs = self.get_attrs( - [value_from_row(r, self.lookup.columns) for _, v in data for r in v] + attrs = cast( + Mapping[tuple[object, ...], object], + self.get_attrs( + [value_from_row(r, self.lookup.columns) for _, v in data for r in v] + ), ) rv = [] for k, v in data: @@ -124,7 +165,7 @@ def serialize( row.append(item) rv.append((k, row)) - res = { + res: StatsTimeSeriesResult = { "data": ( zerofill( rv, @@ -172,4 +213,4 @@ def serialize( res["start"] = timeframe["start"] res["end"] = timeframe["end"] - return res + return dict(res) diff --git a/src/sentry/db/models/fields/array.py b/src/sentry/db/models/fields/array.py index 11b3d1423c66..a478358cd1c8 100644 --- a/src/sentry/db/models/fields/array.py +++ b/src/sentry/db/models/fields/array.py @@ -6,8 +6,11 @@ from __future__ import annotations import ast +from collections.abc import Iterable +from typing import Any from django.db import models +from django.db.backends.base.base import BaseDatabaseWrapper from sentry.db.models.utils import Creator from sentry.utils import json @@ -15,8 +18,15 @@ # Adapted from django-pgfields # https://github.com/lukesneeringer/django-pgfields/blob/master/django_pg/models/fields/array.py -class ArrayField(models.Field): - def __init__(self, of=models.TextField, **kwargs): +class ArrayField(models.Field[object, object]): + of: models.Field[object, object] + + def __init__( + self, + of: type[models.Field[object, object]] | models.Field[object, object] = models.TextField, + # justification: Django field constructors accept arbitrary keyword options. + **kwargs: Any, + ): # Arrays in PostgreSQL are arrays of a particular type. # Save the subtype in our field class. if isinstance(of, type): @@ -30,7 +40,9 @@ def __init__(self, of=models.TextField, **kwargs): super().__init__(**kwargs) - def contribute_to_class(self, cls: type[models.Model], name: str, private_only: bool = False): + def contribute_to_class( + self, cls: type[models.Model], name: str, private_only: bool = False + ) -> None: """ Add a descriptor for backwards compatibility with previous Django behavior. @@ -38,13 +50,13 @@ def contribute_to_class(self, cls: type[models.Model], name: str, private_only: super().contribute_to_class(cls, name, private_only=private_only) setattr(cls, name, Creator(self)) - def db_type(self, connection) -> str: + def db_type(self, connection: BaseDatabaseWrapper) -> str: return f"{self.of.db_type(connection)}[]" def get_internal_type(self) -> str: return "TextField" - def get_prep_value(self, value): + def get_prep_value(self, value: Iterable[object]) -> list[object]: """Iterate over each item in the array, and run it through the `get_prep_value` of this array's type. """ @@ -56,7 +68,8 @@ def get_prep_value(self, value): # our array. return [self.of.get_prep_value(item) for item in value] - def to_python(self, value): + # justification: database values are arbitrary JSON-ish payloads. + def to_python(self, value: Any) -> list[object]: if not value: value = [] if isinstance(value, str): diff --git a/src/sentry/models/eventerror.py b/src/sentry/models/eventerror.py index 16c6fde848d7..0440dd53b410 100644 --- a/src/sentry/models/eventerror.py +++ b/src/sentry/models/eventerror.py @@ -67,6 +67,10 @@ class EventErrorApiContext(TypedDict): data: dict[str, Any] +# justification: event error payloads carry arbitrary SDK-supplied values. +ErrorData = dict[str, Any] + + class EventError: _messages: dict[str, str] = { EventErrorType.UNKNOWN_ERROR: "Unknown error", @@ -114,22 +118,22 @@ class EventError: } @classmethod - def get_message(cls, data): + def get_message(cls, data: ErrorData) -> str: return cls(data).message - def __init__(self, data): + def __init__(self, data: ErrorData) -> None: self._data = data @property - def type(self): + def type(self) -> str: return self._data["type"] @property - def data(self): + def data(self) -> ErrorData: return {k: v for k, v in self._data.items() if k != "type"} @property - def message(self): + def message(self) -> str: return self._messages.get(self._data["type"], self._messages["unknown_error"]) def get_api_context(self) -> EventErrorApiContext: diff --git a/src/sentry/options/manager.py b/src/sentry/options/manager.py index e6047c4baf27..b56eb475aa3f 100644 --- a/src/sentry/options/manager.py +++ b/src/sentry/options/manager.py @@ -30,6 +30,11 @@ NoneType = type(None) + +def _is_none_type(value: object) -> bool: + return value is NoneType + + # Returned by a read hook when it has no value to serve for the key. A unique # object so it can never collide with a value a hook might legitimately return. READ_HOOK_FALLBACK = object() @@ -203,7 +208,13 @@ def set_read_hook(self, hook: ReadHook | None) -> None: """ self._read_hook = hook - def set(self, key: str, value, coerce=True, channel: UpdateChannel = UpdateChannel.UNKNOWN): + def set( + self, + key: str, + value: object, + coerce: bool = True, + channel: UpdateChannel = UpdateChannel.UNKNOWN, + ) -> bool | None: """ Set the value for an option. If the cache is unavailable the action will still succeed. @@ -306,7 +317,7 @@ def is_set_on_disk(self, key: str) -> bool: """ return key in settings.SENTRY_OPTIONS - def get(self, key: str, silent=False): + def get(self, key: str, silent: bool = False) -> TAny: """ Get the value of an option, falling back to the local configuration. @@ -373,7 +384,7 @@ def get(self, key: str, silent=False): record_option(key, optval) return optval - def delete(self, key: str): + def delete(self, key: str) -> bool: """ Permanently remove the value of an option. @@ -395,14 +406,14 @@ def delete(self, key: str): def register( self, key: str, - default=None, - type=None, + default: object | Callable[[], object] | None = None, + type: Type[TAny] | None = None, flags: int = DEFAULT_FLAGS, ttl: int = DEFAULT_KEY_TTL, grace: int = DEFAULT_KEY_GRACE, # Optional info about how to group options together in the _admin ui. Only applies to # options marked `FLAG_ADMIN_MODIFIABLE` - grouping_info=None, + grouping_info: GroupingInfo | None = None, ) -> None: assert key not in self.registry, "Option already registered: %r" % key @@ -445,7 +456,7 @@ def default() -> object: # We disallow None as a value for options since this is ambiguous and doesn't # really make sense as config options. There should be a sensible default # value instead that matches the type expected, rather than relying on None. - if type is NoneType: + if _is_none_type(type): raise TypeError("Options must not be None") # Make sure the type is correct at registration time diff --git a/src/sentry/seer/anomaly_detection/utils.py b/src/sentry/seer/anomaly_detection/utils.py index 7620c4f36fb3..1ece1a2d9dfe 100644 --- a/src/sentry/seer/anomaly_detection/utils.py +++ b/src/sentry/seer/anomaly_detection/utils.py @@ -174,7 +174,7 @@ def format_snuba_ts_data( extra_columns=None, ) - for data in serialized_result.get("data"): + for data in serialized_result.get("data") or []: if len(data) > 1: count_data = data[1] count = 0 diff --git a/src/sentry/tasks/statistical_detectors.py b/src/sentry/tasks/statistical_detectors.py index cdf21142f95a..84adc8e183a4 100644 --- a/src/sentry/tasks/statistical_detectors.py +++ b/src/sentry/tasks/statistical_detectors.py @@ -2,7 +2,7 @@ import contextlib import logging -from collections.abc import Generator, Iterable, Sequence +from collections.abc import Generator, Iterable, Mapping, Sequence from datetime import UTC, datetime, timedelta from typing import Any, cast @@ -74,7 +74,7 @@ TIMESERIES_PER_BATCH = 10 -def get_performance_issue_settings(projects: list[Project]): +def get_performance_issue_settings(projects: list[Project]) -> dict[Project, dict[str, object]]: project_settings = {} project_option_settings = ProjectOption.objects.get_value_bulk( @@ -182,7 +182,9 @@ def detector_algorithm_factory(cls) -> DetectorAlgorithm: ) @classmethod - def detector_store_factory(cls) -> DetectorStore: + def detector_store_factory( + cls, + ) -> DetectorStore[Mapping[str | bytes, bytes | float | int | str]]: return RedisDetectorStore(regression_type=RegressionType.FUNCTION) @classmethod @@ -208,7 +210,9 @@ def query_timeseries( namespace=profiling_tasks, processing_deadline_duration=30, ) -def detect_function_trends(project_ids: list[int], start: str, *args, **kwargs) -> None: +def detect_function_trends( + project_ids: list[int], start: str, *args: object, **kwargs: object +) -> None: if not options.get("statistical_detectors.enable"): return @@ -247,8 +251,8 @@ def detect_function_trends(project_ids: list[int], start: str, *args, **kwargs) def detect_function_change_points( functions_list: list[tuple[int, int]] | None = None, start: str | None = None, - *args, - **kwargs, + *args: object, + **kwargs: object, ) -> None: if not options.get("statistical_detectors.enable"): return @@ -295,7 +299,10 @@ def process_function_change_points(candidates: list[tuple[int, str, str]]) -> No def _detect_function_change_points( - functions_list: Sequence[tuple[int, int | str]], start: datetime, *args, **kwargs + functions_list: Sequence[tuple[int, int | str]], + start: datetime, + *args: object, + **kwargs: object, ) -> None: if not options.get("statistical_detectors.enable"): return @@ -486,7 +493,9 @@ def emit_function_regression_issue( return len(data["occurrence_payloads"]) -def fetch_continuous_examples(raw_examples): +def fetch_continuous_examples( + raw_examples: Mapping[tuple[int, int], Mapping[str, datetime | str | float | None]], +) -> Mapping[tuple[int, int], Mapping[str, datetime | str | float | None]]: if not raw_examples: return raw_examples diff --git a/src/sentry/utils/types.py b/src/sentry/utils/types.py index ae15e75a25be..cdd6ac6ed9da 100644 --- a/src/sentry/utils/types.py +++ b/src/sentry/utils/types.py @@ -123,7 +123,7 @@ class StringType(Type[str]): compatible_types = (str,) -class DictType(Type[dict]): +class DictType(Type[dict[str, TAny]]): """Coerce a dict out of a json/yaml string""" name = "dictionary" @@ -140,7 +140,7 @@ def convert(self, value: TAny) -> dict[str, TAny] | None: return None -class SequenceType(Type[list]): +class SequenceType(Type[list[TAny]]): """Coerce a list out of a json/yaml string or a list""" name = "sequence" @@ -172,7 +172,7 @@ def convert(self, value: TAny) -> list[TAny] | None: Sequence = SequenceType() # Mapping for basic types into what their Type is -_type_mapping: dict[type[object], Type] = { +_type_mapping: dict[type[object], Type[TAny]] = { bool: Bool, int: Int, float: Float, @@ -204,14 +204,18 @@ def type_from_value(value: str) -> StringType: ... @typing.overload -def type_from_value(value: dict) -> DictType: ... +def type_from_value(value: dict[TAny, TAny]) -> DictType: ... @typing.overload -def type_from_value(value: list) -> SequenceType: ... +def type_from_value(value: list[TAny]) -> SequenceType: ... -def type_from_value(value): +@typing.overload +def type_from_value(value: object) -> Type[TAny]: ... + + +def type_from_value(value: object) -> Type[TAny]: """Fetch Type based on a primitive value""" return _type_mapping[type(value)] diff --git a/src/sentry/web/frontend/generic.py b/src/sentry/web/frontend/generic.py index 4cd2319eacb0..01db35ab87b4 100644 --- a/src/sentry/web/frontend/generic.py +++ b/src/sentry/web/frontend/generic.py @@ -4,7 +4,14 @@ from django.conf import settings from django.contrib.staticfiles import finders -from django.http import Http404, HttpResponse, HttpResponseNotFound, HttpResponseNotModified +from django.http import ( + Http404, + HttpRequest, + HttpResponse, + HttpResponseBase, + HttpResponseNotFound, + HttpResponseNotModified, +) from django.views import static from requests.exceptions import RequestException @@ -18,12 +25,12 @@ from sentry.web.frontend.base import all_silo_view, control_silo_view -def dev_favicon(request, extension): +def dev_favicon(request: HttpRequest, extension: str) -> HttpResponseBase: document_root, path = resolve("sentry/images/favicon-dev.png") return static.serve(request, path, document_root=document_root) -def resolve(path): +def resolve(path: str) -> tuple[str, str]: # Mostly yanked from Django core and changed to return the path: # See: https://github.com/django/django/blob/1.6.11/django/contrib/staticfiles/views.py normalized_path = posixpath.normpath(unquote(path)).lstrip("/") @@ -41,7 +48,7 @@ def resolve(path): @all_silo_view -def frontend_app_static_media(request, **kwargs): +def frontend_app_static_media(request: HttpRequest, **kwargs: str) -> HttpResponseBase: """ Serve static files that should not have any versioned paths/filenames. These assets will have cache headers to say that it can be cached by a @@ -102,7 +109,7 @@ def _fetch_worker_bundle(commit_sha: str) -> bytes | None: @control_silo_view -def service_worker(request): +def service_worker(request: HttpRequest) -> HttpResponseBase: """ Serve the service worker script from our own origin. @@ -153,7 +160,7 @@ def service_worker(request): @all_silo_view -def static_media(request, **kwargs): +def static_media(request: HttpRequest, **kwargs: str) -> HttpResponseBase: """ Serve static files below a given point in the directory structure. """ diff --git a/src/social_auth/fields.py b/src/social_auth/fields.py index 252a7262d775..49a114c3b00e 100644 --- a/src/social_auth/fields.py +++ b/src/social_auth/fields.py @@ -10,7 +10,7 @@ from sentry.utils import json -class JSONField(TextField): +class JSONField(TextField[object, object]): """Simple JSON field that stores python structures as JSON strings on database. """ @@ -23,7 +23,7 @@ def contribute_to_class(self, cls: type[Model], name: str, private_only: bool = super().contribute_to_class(cls, name, private_only=private_only) setattr(cls, name, Creator(self)) - def to_python(self, value): + def to_python(self, value: object) -> object: """ Convert the input JSON value into python structures, raises django.core.exceptions.ValidationError if the data can't be converted. @@ -38,7 +38,7 @@ def to_python(self, value): else: return value - def validate(self, value, model_instance): + def validate(self, value: object, model_instance: Model | None) -> None: """Check value is a valid JSON string, raise ValidationError on error.""" if isinstance(value, str): @@ -48,17 +48,17 @@ def validate(self, value, model_instance): except Exception as e: raise ValidationError(str(e)) - def get_prep_value(self, value): + def get_prep_value(self, value: object) -> str: """Convert value to JSON string before save""" try: return json.dumps(value) except Exception as e: raise ValidationError(str(e)) - def value_to_string(self, obj): + def value_to_string(self, obj: Model) -> str: """Return value from object converted to string properly""" return smart_str(self.value_from_object(obj)) - def value_from_object(self, obj): + def value_from_object(self, obj: Model) -> str: """Return value dumped to string.""" return self.get_prep_value(super().value_from_object(obj)) From 53aaef854a9d1378526bad4603e037f8cb1bd203 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:58:21 -0700 Subject: [PATCH 055/103] ref(typing): remove lane 11 weaklist entries Integrate the ten graduated modules and keep the options API's historical truthy second argument without changing runtime behavior. --- pyproject.toml | 10 ---------- src/sentry/options/manager.py | 4 ++-- tests/sentry/options/test_manager.py | 5 +++-- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 519f644e6378..8ed888bce89b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -468,7 +468,6 @@ module = [ "sentry.api.endpoints.organization_trace_item_attributes", "sentry.api.endpoints.organization_trace_item_stats", "sentry.api.endpoints.organization_traces", - "sentry.api.endpoints.organization_unsubscribe", "sentry.api.endpoints.project_artifact_bundle_files", "sentry.api.endpoints.project_trace_item_details", "sentry.api.endpoints.project_transaction_threshold_override", @@ -479,11 +478,9 @@ module = [ "sentry.api.serializers.models.event", "sentry.api.serializers.models.group_stream", "sentry.api.serializers.models.groupseen", - "sentry.api.serializers.models.project", "sentry.api.serializers.models.userrollback", "sentry.api.serializers.rest_framework.base", "sentry.api.serializers.rest_framework.dashboard", - "sentry.api.serializers.snuba", "sentry.assistant.*", "sentry.attachments.*", "sentry.backup.*", @@ -493,7 +490,6 @@ module = [ "sentry.consumers.*", "sentry.core.*", "sentry.db.analyze", - "sentry.db.models.fields.array", "sentry.db.models.fields.bounded", "sentry.db.models.fields.encryption.*", "sentry.db.models.fields.jsonfield", @@ -579,7 +575,6 @@ module = [ "sentry.models.dashboard_widget", "sentry.models.deploy", "sentry.models.environment", - "sentry.models.eventerror", "sentry.models.featureadoption", "sentry.models.files.*", "sentry.models.group", @@ -611,7 +606,6 @@ module = [ "sentry.new_migrations.*", "sentry.newsletter.*", "sentry.onboarding_tasks.*", - "sentry.options.manager", "sentry.options.store", "sentry.plugins.base.binding_manager", "sentry.plugins.interfaces.*", @@ -672,7 +666,6 @@ module = [ "sentry.tasks.relay", "sentry.tasks.seer.backfill_supergroups_lightweight", "sentry.tasks.seer.context_engine_index", - "sentry.tasks.statistical_detectors", "sentry.tasks.summaries.*", "sentry.tasks.user_report", "sentry.templatetags.*", @@ -716,17 +709,14 @@ module = [ "sentry.utils.sdk", "sentry.utils.session_store", "sentry.utils.snuba", - "sentry.utils.types", "sentry.utils.validators", "sentry.web.api", "sentry.web.forms.*", "sentry.web.frontend.disabled_member_view", - "sentry.web.frontend.generic", "sentry.web.frontend.mailgun_inbound_webhook", "sentry.web.frontend.react_page", "sentry.web.frontend.setup_wizard", "social_auth.backends.*", - "social_auth.fields", "social_auth.models", "social_auth.utils", "tests", diff --git a/src/sentry/options/manager.py b/src/sentry/options/manager.py index b56eb475aa3f..b3233de7bf35 100644 --- a/src/sentry/options/manager.py +++ b/src/sentry/options/manager.py @@ -317,7 +317,7 @@ def is_set_on_disk(self, key: str) -> bool: """ return key in settings.SENTRY_OPTIONS - def get(self, key: str, silent: bool = False) -> TAny: + def get(self, key: str, silent: object = False) -> TAny: """ Get the value of an option, falling back to the local configuration. @@ -363,7 +363,7 @@ def get(self, key: str, silent: bool = False) -> TAny: return result if not (opt.flags & FLAG_NOSTORE): - result = self.store.get(opt, silent=silent) + result = self.store.get(opt, silent=bool(silent)) if result is not None: tags["source"] = "store" record_option(key, result) diff --git a/tests/sentry/options/test_manager.py b/tests/sentry/options/test_manager.py index 3c6193b34fa5..d93b4b5a1237 100644 --- a/tests/sentry/options/test_manager.py +++ b/tests/sentry/options/test_manager.py @@ -1,5 +1,6 @@ from collections.abc import Generator from functools import cached_property +from typing import cast from unittest.mock import patch import pytest @@ -25,7 +26,7 @@ from sentry.options.store import OptionsStore from sentry.testutils.cases import TestCase from sentry.testutils.silo import all_silo_test -from sentry.utils.types import Int, String +from sentry.utils.types import Int, String, Type @all_silo_test @@ -98,7 +99,7 @@ def test_register(self) -> None: self.manager.register("wrong-type", default=1, type=String) with pytest.raises(TypeError): - self.manager.register("none-type", default=None, type=type(None)) + self.manager.register("none-type", default=None, type=cast(Type[object], type(None))) with pytest.raises(ValueError): self.manager.register("bad_flags", flags=FLAG_NOSTORE | FLAG_ADMIN_MODIFIABLE) From 2d30e437c6c963f3352d3b3652c61f300436a678 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 13:55:47 -0700 Subject: [PATCH 056/103] ref(typing): graduate lane 13 wildcard packages --- .../api/endpoints/relay/project_configs.py | 19 +++-- .../services/quota/quota_config_mapping.py | 20 ++--- .../services/usage/_outcomes_query.py | 20 ++++- .../services/usage/_project_outcomes_query.py | 7 +- src/sentry/ingest/consumer/factory.py | 5 +- src/sentry/ingest/consumer/processors.py | 15 ++-- src/sentry/integrations/cursor/integration.py | 4 +- src/sentry/integrations/cursor/models.py | 2 +- .../integrations/cursor/webhooks/handler.py | 2 +- .../data_forwarding/amazon_sqs/forwarder.py | 10 ++- .../data_forwarding/splunk/forwarder.py | 10 ++- .../slack/actions/notification.py | 8 +- tests/sentry/attachments/test_base.py | 12 +-- .../incidents/utils/test_format_duration.py | 2 +- .../incidents/utils/test_metric_issue_base.py | 2 +- .../utils/test_process_update_helpers.py | 2 +- .../utils/test_subscription_limits.py | 3 +- .../processing_errors/test_manager.py | 10 +-- .../fetch_issues/test_by_function_name.py | 81 ++++++++++++------- .../seer/fetch_issues/test_by_text_query.py | 33 +++++--- tests/sentry/toolbar/utils/test_url.py | 2 +- .../sentry/toolbar/views/test_iframe_view.py | 6 +- .../toolbar/views/test_login_success_view.py | 3 +- 23 files changed, 171 insertions(+), 107 deletions(-) diff --git a/src/sentry/api/endpoints/relay/project_configs.py b/src/sentry/api/endpoints/relay/project_configs.py index 6a5941cec3fb..f0b763a12882 100644 --- a/src/sentry/api/endpoints/relay/project_configs.py +++ b/src/sentry/api/endpoints/relay/project_configs.py @@ -39,7 +39,7 @@ class RelayProjectConfigsEndpoint(Endpoint): permission_classes = (RelayPermission,) enforce_rate_limit = False - def post(self, request: Request): + def post(self, request: Request) -> Response: relay = request.relay assert relay is not None # should be provided during Authentication response: dict[str, Any] = {} @@ -69,7 +69,7 @@ def post(self, request: Request): return Response(response, status=200) - def _should_post_or_schedule(self, version, request): + def _should_post_or_schedule(self, version: str, request: Request) -> bool: """Determine whether the `_post_or_schedule_by_key` function should be used for project configs. @@ -115,11 +115,11 @@ def _should_post_or_schedule(self, version, request): return post_or_schedule - def _post_or_schedule_by_key(self, request: Request): - public_keys = set(request.relay_request_data.get("publicKeys") or ()) + def _post_or_schedule_by_key(self, request: Request) -> dict[str, object]: + public_keys: set[str] = set(request.relay_request_data.get("publicKeys") or ()) - proj_configs = {} - pending = [] + proj_configs: dict[str, ProjectConfig] = {} + pending: list[str] = [] for key in public_keys: computed = self._get_cached_or_schedule(key) if not computed: @@ -135,14 +135,14 @@ def _post_or_schedule_by_key(self, request: Request): metrics.incr("relay.project_configs.post_v3.fetched", amount=len(proj_configs)) return {"configs": proj_configs, "pending": pending} - def _get_cached_or_schedule(self, public_key) -> dict | None: + def _get_cached_or_schedule(self, public_key: str) -> ProjectConfig | None: """ Returns the config of a project if it's in the cache; else, schedules a task to compute and write it into the cache. Debouncing of the project happens after the task has been scheduled. """ - cached_config = projectconfig_cache.backend.get(public_key) + cached_config: ProjectConfig | None = projectconfig_cache.backend.get(public_key) if cached_config: return cached_config @@ -150,8 +150,7 @@ def _get_cached_or_schedule(self, public_key) -> dict | None: return None def _post_by_key(self, request: Request) -> MutableMapping[str, ProjectConfig]: - public_keys = request.relay_request_data.get("publicKeys") - public_keys = set(public_keys or ()) + public_keys: set[str] = set(request.relay_request_data.get("publicKeys") or ()) project_keys: MutableMapping[str, ProjectKey] = {} project_ids: set[int] = set() diff --git a/src/sentry/billing/platform/services/quota/quota_config_mapping.py b/src/sentry/billing/platform/services/quota/quota_config_mapping.py index e9306c429109..caa6770fa9f5 100644 --- a/src/sentry/billing/platform/services/quota/quota_config_mapping.py +++ b/src/sentry/billing/platform/services/quota/quota_config_mapping.py @@ -17,23 +17,23 @@ def sentry_to_proto_quota_config(quota: QuotaConfig) -> ProtoQuotaConfig: """Convert a sentry QuotaConfig to its proto equivalent.""" - kwargs: dict = { - "id": quota.id or "", - "categories": sorted(sentry_to_proto_category(int(c)) for c in quota.categories), - "scope": ProtoQuotaScope.ValueType(int(quota.scope)) + proto_quota = ProtoQuotaConfig( + id=quota.id or "", + categories=sorted(sentry_to_proto_category(int(c)) for c in quota.categories), + scope=ProtoQuotaScope.ValueType(int(quota.scope)) if quota.scope is not None else ProtoQuotaScope.QUOTA_SCOPE_UNSPECIFIED, - } + ) if quota.scope_id is not None: - kwargs["scope_id"] = quota.scope_id + proto_quota.scope_id = quota.scope_id if quota.limit is not None: - kwargs["limit"] = quota.limit + proto_quota.limit = quota.limit if quota.window is not None: - kwargs["window"] = quota.window + proto_quota.window = quota.window if quota.reason_code is not None: - kwargs["reason_code"] = quota.reason_code + proto_quota.reason_code = quota.reason_code - return ProtoQuotaConfig(**kwargs) + return proto_quota def proto_to_sentry_quota_config(proto_quota: ProtoQuotaConfig) -> QuotaConfig | None: diff --git a/src/sentry/billing/platform/services/usage/_outcomes_query.py b/src/sentry/billing/platform/services/usage/_outcomes_query.py index 85ef5a5c8e24..e907e4bd250f 100644 --- a/src/sentry/billing/platform/services/usage/_outcomes_query.py +++ b/src/sentry/billing/platform/services/usage/_outcomes_query.py @@ -4,6 +4,7 @@ from collections import defaultdict from collections.abc import Sequence from datetime import datetime, timedelta, timezone +from typing import TypedDict from google.protobuf.timestamp_pb2 import Timestamp from sentry_protos.billing.v1.data_category_pb2 import DataCategory as ProtoDataCategory @@ -52,6 +53,19 @@ _BILLABLE_OUTCOMES = [Outcome.ACCEPTED, Outcome.FILTERED, Outcome.RATE_LIMITED] +class _OutcomeRow(TypedDict): + category: int + time: str + max_ts: str + total: int + accepted: int + dropped: int + filtered: int + over_quota: int + spike_protection: int + dynamic_sampling: int + + def query_outcomes_usage(request: GetUsageRequest) -> GetUsageResponse: org_id = request.organization_id start = _timestamp_to_datetime(request.start) @@ -78,7 +92,7 @@ def query_outcomes_usage(request: GetUsageRequest) -> GetUsageResponse: return _build_response(rows, _latest_usage_timestamp(rows)) -def _latest_usage_timestamp(rows: list[dict]) -> datetime | None: +def _latest_usage_timestamp(rows: Sequence[_OutcomeRow]) -> datetime | None: last_usage_ts: datetime | None = None for row in rows: row_max = row.get("max_ts") @@ -139,7 +153,7 @@ def _build_query( ) -def _build_response(rows: list[dict], last_usage_ts: datetime | None) -> GetUsageResponse: +def _build_response(rows: list[_OutcomeRow], last_usage_ts: datetime | None) -> GetUsageResponse: # Two-level accumulator: days_map[day_str][category_id] -> category usage. # Each row already contains all 7 sumIf-aggregated fields from ClickHouse. # @@ -220,7 +234,7 @@ def _usage_aggregates(total_outcomes: Sequence[int] | None) -> list[Function]: ] -def _category_usage_from_row(row: dict) -> CategoryUsage | None: +def _category_usage_from_row(row: _OutcomeRow) -> CategoryUsage | None: category = sentry_to_proto_category(int(row["category"])) if category == ProtoDataCategory.DATA_CATEGORY_UNKNOWN: # Sentry category has no proto mapping; skipping prevents it diff --git a/src/sentry/billing/platform/services/usage/_project_outcomes_query.py b/src/sentry/billing/platform/services/usage/_project_outcomes_query.py index 58a6cbbc3ce2..ba8807d860df 100644 --- a/src/sentry/billing/platform/services/usage/_project_outcomes_query.py +++ b/src/sentry/billing/platform/services/usage/_project_outcomes_query.py @@ -21,6 +21,7 @@ _category_usage_from_row, _inclusive_end_to_exclusive, _latest_usage_timestamp, + _OutcomeRow, _parse_day, _timestamp_to_datetime, ) @@ -33,6 +34,10 @@ _REFERRER = Referrer.BILLING_USAGE_SERVICE_CLICKHOUSE.value +class _ProjectOutcomeRow(_OutcomeRow): + project_id: int + + class ProjectUsageQueryTruncatedError(RuntimeError): """Raised when ClickHouse may have omitted project usage rows.""" @@ -82,7 +87,7 @@ def _build_project_query( def _build_project_response( - rows: list[dict], last_usage_ts: datetime | None + rows: list[_ProjectOutcomeRow], last_usage_ts: datetime | None ) -> GetUsageByProjectResponse: projects: defaultdict[int, defaultdict[str, dict[int, CategoryUsage]]] = defaultdict( lambda: defaultdict(dict) diff --git a/src/sentry/ingest/consumer/factory.py b/src/sentry/ingest/consumer/factory.py index 696f497a6886..0c82878da172 100644 --- a/src/sentry/ingest/consumer/factory.py +++ b/src/sentry/ingest/consumer/factory.py @@ -19,6 +19,7 @@ from sentry.utils.arroyo import MultiprocessingPool, run_task_with_multiprocessing from .attachment_event import decode_and_process_chunks, process_attachments_and_events +from .processors import IngestMessage from .simple_event import process_simple_event_message @@ -149,7 +150,7 @@ def create_with_partitions( # later step. assert self._attachments_pool is not None - processing_function = partial( + processing_function: Callable[[Message[IngestMessage | None]], None] = partial( process_attachments_and_events, reprocess_only_stuck_events=self.reprocess_only_stuck_events, reprocess_only_events_not_in_nodestore=self.reprocess_only_events_not_in_nodestore, @@ -162,7 +163,7 @@ def create_with_partitions( # to avoid arroyo from needlessly dispatching `None` messages. # However its currently not possible to make that `| None` disappear in the type. - def filter_fn(msg): + def filter_fn(msg: Message[FilteredPayload | IngestMessage | None]) -> bool: if not bool(msg.payload): return False diff --git a/src/sentry/ingest/consumer/processors.py b/src/sentry/ingest/consumer/processors.py index c54e7425ecf8..b505e4e13fec 100644 --- a/src/sentry/ingest/consumer/processors.py +++ b/src/sentry/ingest/consumer/processors.py @@ -1,8 +1,8 @@ import functools import logging import os -from collections.abc import Mapping, MutableMapping -from typing import Any +from collections.abc import Callable, Mapping, MutableMapping +from typing import Any, ParamSpec, TypeVar import orjson import sentry_sdk @@ -41,15 +41,18 @@ IngestMessage = Mapping[str, Any] +P = ParamSpec("P") +R = TypeVar("R") + class Retriable(Exception): pass -def trace_func(name: str): - def wrapper(f): +def trace_func(name: str) -> Callable[[Callable[P, R]], Callable[P, R]]: + def wrapper(f: Callable[P, R]) -> Callable[P, R]: @functools.wraps(f) - def inner(*args, **kwargs): + def inner(*args: P.args, **kwargs: P.kwargs) -> R: # First we check a local env var # if that's not set we check our conf file # if neither are set use 0 @@ -448,7 +451,7 @@ def process_userreport(message: IngestMessage, project: Project) -> bool: def collect_span_metrics( project: Project, data: MutableMapping[str, Any], -): +) -> None: if not features.has("organizations:am3-tier", project.organization) and not features.has( "organizations:dynamic-sampling", project.organization ): diff --git a/src/sentry/integrations/cursor/integration.py b/src/sentry/integrations/cursor/integration.py index 90d12353b4e9..88e5680b2b2b 100644 --- a/src/sentry/integrations/cursor/integration.py +++ b/src/sentry/integrations/cursor/integration.py @@ -78,7 +78,7 @@ def resolve_integration_name(metadata: CursorIntegrationMetadata) -> str: ) -class CursorApiKeySerializer(CamelSnakeSerializer): +class CursorApiKeySerializer(CamelSnakeSerializer[dict[str, str]]): api_key = CharField(required=True, max_length=255) @@ -256,7 +256,7 @@ def _update_display_name( ) metadata.display_name = display_name or None - def get_client(self): + def get_client(self) -> CursorAgentClient: return CursorAgentClient( api_key=self.api_key, webhook_secret=self.webhook_secret, diff --git a/src/sentry/integrations/cursor/models.py b/src/sentry/integrations/cursor/models.py index cf904bcde430..262bf754efb1 100644 --- a/src/sentry/integrations/cursor/models.py +++ b/src/sentry/integrations/cursor/models.py @@ -12,7 +12,7 @@ class CursorApiKeyMetadata(BaseModel): class CursorAgentLaunchRequestPrompt(BaseModel): text: str - images: list[dict] = [] + images: list[dict[str, object]] = [] class CursorAgentSource(BaseModel): diff --git a/src/sentry/integrations/cursor/webhooks/handler.py b/src/sentry/integrations/cursor/webhooks/handler.py index c841d1f173c8..ccf2e138b584 100644 --- a/src/sentry/integrations/cursor/webhooks/handler.py +++ b/src/sentry/integrations/cursor/webhooks/handler.py @@ -55,7 +55,7 @@ class CursorWebhookEndpoint(Endpoint): organization_id: int @method_decorator(csrf_exempt) - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def dispatch(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: if request.method != "POST": raise MethodNotAllowed(request.method or "unknown") return super().dispatch(request, *args, **kwargs) diff --git a/src/sentry/integrations/data_forwarding/amazon_sqs/forwarder.py b/src/sentry/integrations/data_forwarding/amazon_sqs/forwarder.py index c1f1bf6bc27b..08c04491c69a 100644 --- a/src/sentry/integrations/data_forwarding/amazon_sqs/forwarder.py +++ b/src/sentry/integrations/data_forwarding/amazon_sqs/forwarder.py @@ -85,13 +85,14 @@ def forward_event( "region_name": region, } - def s3_put_object(*args, **kwargs): + def s3_put_object(*args: object, **kwargs: object) -> object: s3_client = boto3.client( service_name="s3", config=Config(signature_version="s3v4"), **boto3_args ) - return s3_client.put_object(*args, **kwargs) + response: object = s3_client.put_object(*args, **kwargs) + return response - def sqs_send_message(message): + def sqs_send_message(message: str) -> object: client = boto3.client(service_name="sqs", **boto3_args) send_message_args = {"QueueUrl": queue_url, "MessageBody": message} @@ -103,7 +104,8 @@ def sqs_send_message(message): # if content based de-duplication is not enabled, we need to provide a # MessageDeduplicationId send_message_args["MessageDeduplicationId"] = uuid4().hex - return client.send_message(**send_message_args) + response: object = client.send_message(**send_message_args) + return response # Upload to S3 if configured (for large payloads) if s3_bucket: diff --git a/src/sentry/integrations/data_forwarding/splunk/forwarder.py b/src/sentry/integrations/data_forwarding/splunk/forwarder.py index ad2dbe28e582..c1e2eb4e26fd 100644 --- a/src/sentry/integrations/data_forwarding/splunk/forwarder.py +++ b/src/sentry/integrations/data_forwarding/splunk/forwarder.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from collections.abc import Mapping from typing import Any from sentry import tagstore @@ -21,14 +22,14 @@ class SplunkApiClient(BaseApiClient): plugin_name = "splunk" allow_redirects = False - def __init__(self, endpoint, token): + def __init__(self, endpoint: str, token: str) -> None: self.endpoint = endpoint self.token = token super().__init__(verify_ssl=False) - def request(self, data): + def request(self, data: Mapping[str, object]) -> object: headers = {"Authorization": f"Splunk {self.token}"} - return self._request( + response: object = self._request( path=self.endpoint, method="post", data=data, @@ -37,6 +38,7 @@ def request(self, data): timeout=5, allow_text=True, ) + return response class SplunkForwarder(BaseDataForwarder): @@ -61,7 +63,7 @@ def get_host_for_splunk(self, event: Event | GroupEvent) -> str | None: return None - def initialize_variables(self, event: Event | GroupEvent, config: dict[str, Any]): + def initialize_variables(self, event: Event | GroupEvent, config: dict[str, Any]) -> None: self.project_token = config.get("token") self.project_index = config.get("index") self.project_instance = config.get("instance_url") diff --git a/src/sentry/integrations/slack/actions/notification.py b/src/sentry/integrations/slack/actions/notification.py index 44fe8f6d7543..2c70d09c1cd7 100644 --- a/src/sentry/integrations/slack/actions/notification.py +++ b/src/sentry/integrations/slack/actions/notification.py @@ -70,7 +70,7 @@ def _build_notification_blocks( self, event: GroupEvent, rules: Sequence[NotificationOrigin], - tags: set, + tags: set[str], integration: RpcIntegration, notification_uuid: str | None = None, send_nudge: bool = False, @@ -161,12 +161,12 @@ def _send_notification( self, event: GroupEvent, futures: Sequence[RuleFuture], - tags: set, + tags: set[str], integration: RpcIntegration, channel: str, notification_uuid: str | None = None, notification_message_object: NewNotificationActionNotificationMessage | None = None, - save_notification_method: Callable | None = None, + save_notification_method: Callable[..., None] | None = None, thread_ts: str | None = None, send_nudge: bool = False, ) -> None: @@ -224,7 +224,7 @@ def _send_notification_action_notification( self, event: GroupEvent, futures: Sequence[RuleFuture], - tags: set, + tags: set[str], integration: RpcIntegration, channel: str, notification_uuid: str | None = None, diff --git a/tests/sentry/attachments/test_base.py b/tests/sentry/attachments/test_base.py index f1a98540f8c3..74dc6d9804d4 100644 --- a/tests/sentry/attachments/test_base.py +++ b/tests/sentry/attachments/test_base.py @@ -11,23 +11,23 @@ class InMemoryCache: internal assertions to ensure correct use of `raw`. """ - def __init__(self): - self.data = {} + def __init__(self) -> None: + self.data: dict[str, object] = {} #: Used to check for consistent usage of `raw` param - self.raw_map = {} + self.raw_map: dict[str, bool] = {} - def get(self, key, raw=False): + def get(self, key: str, raw: bool = False) -> object | None: assert key not in self.raw_map or raw == self.raw_map[key] return copy.deepcopy(self.data.get(key)) - def set(self, key, value, timeout=None, raw=False): + def set(self, key: str, value: object, timeout: int | None = None, raw: bool = False) -> None: # Attachment chunks MUST be bytestrings. Josh please don't change this # to unicode. assert isinstance(value, bytes) or not raw assert key not in self.raw_map or raw == self.raw_map[key] self.data[key] = value - def delete(self, key): + def delete(self, key: str) -> None: del self.data[key] diff --git a/tests/sentry/incidents/utils/test_format_duration.py b/tests/sentry/incidents/utils/test_format_duration.py index 1a53d5f2f438..4f77145915c0 100644 --- a/tests/sentry/incidents/utils/test_format_duration.py +++ b/tests/sentry/incidents/utils/test_format_duration.py @@ -21,5 +21,5 @@ @pytest.mark.parametrize("minutes, expected_str", TEST_CASES) -def test_format_duration(minutes, expected_str) -> None: +def test_format_duration(minutes: int, expected_str: str) -> None: assert expected_str == format_duration_idiomatic(minutes) diff --git a/tests/sentry/incidents/utils/test_metric_issue_base.py b/tests/sentry/incidents/utils/test_metric_issue_base.py index 47a508b9ab36..797c8f8020f0 100644 --- a/tests/sentry/incidents/utils/test_metric_issue_base.py +++ b/tests/sentry/incidents/utils/test_metric_issue_base.py @@ -91,7 +91,7 @@ def create_subscription_packet( ) def process_packet_and_return_result( - self, data_packet: DataPacket + self, data_packet: DataPacket[ProcessedSubscriptionUpdate] ) -> IssueOccurrence | StatusChangeMessage | None: results = process_data_packet(data_packet, DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION) if not results: diff --git a/tests/sentry/incidents/utils/test_process_update_helpers.py b/tests/sentry/incidents/utils/test_process_update_helpers.py index 57f98a729f9c..68c7284980b7 100644 --- a/tests/sentry/incidents/utils/test_process_update_helpers.py +++ b/tests/sentry/incidents/utils/test_process_update_helpers.py @@ -74,7 +74,7 @@ def test_missing_issue_ids_returns_none_gracefully(self) -> None: class GetAggregationValueHelperTest(TestCase): - def _update(self, value): + def _update(self, value: object) -> QuerySubscriptionUpdate: from sentry.incidents.utils.types import QuerySubscriptionUpdate update: QuerySubscriptionUpdate = { diff --git a/tests/sentry/incidents/utils/test_subscription_limits.py b/tests/sentry/incidents/utils/test_subscription_limits.py index 084eb8f8fcc6..b45a473fa4df 100644 --- a/tests/sentry/incidents/utils/test_subscription_limits.py +++ b/tests/sentry/incidents/utils/test_subscription_limits.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from contextlib import contextmanager from unittest.mock import MagicMock, patch @@ -10,7 +11,7 @@ class TestIsMetricSubscriptionAllowed: org = MagicMock(spec=Organization) @contextmanager - def fake_features(self, enabled: set[str]): + def fake_features(self, enabled: set[str]) -> Iterator[None]: with patch("sentry.incidents.utils.subscription_limits.features") as mock_features: mock_features.has.side_effect = lambda name, *a, **kw: name in enabled yield diff --git a/tests/sentry/monitors/processing_errors/test_manager.py b/tests/sentry/monitors/processing_errors/test_manager.py index 9f3ed5939746..7cd6c1635bbd 100644 --- a/tests/sentry/monitors/processing_errors/test_manager.py +++ b/tests/sentry/monitors/processing_errors/test_manager.py @@ -25,7 +25,7 @@ def assert_processing_errors_equal( error_1: CheckinProcessingError, error_2: CheckinProcessingError, -): +) -> None: assert error_1.errors == error_2.errors assert error_2.checkin == error_2.checkin @@ -234,7 +234,7 @@ def test_delete_for_project_by_type(self) -> None: class HandleProcessingErrorsTest(TestCase): @mock.patch("sentry.monitors.processing_errors.manager.ANALYTICS_SAMPLING_RATE", 1.0) @mock.patch("sentry.analytics.record") - def test(self, mock_record) -> None: + def test(self, mock_record: mock.Mock) -> None: monitor = self.create_monitor() exception = ProcessingErrorsException( [{"type": ProcessingErrorType.CHECKIN_INVALID_GUID}], @@ -275,7 +275,7 @@ def test_killswitched_errors_are_never_stored(self) -> None: assert get_errors_for_monitor(monitor) == [] @mock.patch("sentry.monitors.processing_errors.manager.random.random", return_value=0.5) - def test_rate_limited_errors_are_sampled_out(self, mock_random) -> None: + def test_rate_limited_errors_are_sampled_out(self, mock_random: mock.Mock) -> None: monitor = self.create_monitor() handle_processing_errors( build_checkin_item( @@ -290,7 +290,7 @@ def test_rate_limited_errors_are_sampled_out(self, mock_random) -> None: assert get_errors_for_monitor(monitor) == [] @mock.patch("sentry.monitors.processing_errors.manager.random.random", return_value=0.0) - def test_rate_limited_errors_are_sampled_in(self, mock_random) -> None: + def test_rate_limited_errors_are_sampled_in(self, mock_random: mock.Mock) -> None: monitor = self.create_monitor() handle_processing_errors( build_checkin_item( @@ -305,7 +305,7 @@ def test_rate_limited_errors_are_sampled_in(self, mock_random) -> None: assert len(get_errors_for_monitor(monitor)) == 1 @mock.patch("sentry.monitors.processing_errors.manager.random.random", return_value=0.5) - def test_throttled_type_alongside_a_real_error_is_stored(self, mock_random) -> None: + def test_throttled_type_alongside_a_real_error_is_stored(self, mock_random: mock.Mock) -> None: monitor = self.create_monitor() handle_processing_errors( build_checkin_item( diff --git a/tests/sentry/seer/fetch_issues/test_by_function_name.py b/tests/sentry/seer/fetch_issues/test_by_function_name.py index 5acfecbaca1e..c4de002b94ff 100644 --- a/tests/sentry/seer/fetch_issues/test_by_function_name.py +++ b/tests/sentry/seer/fetch_issues/test_by_function_name.py @@ -1,5 +1,5 @@ from datetime import UTC, datetime, timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch from sentry.integrations.github.integration import GitHubIntegrationProvider from sentry.models.group import Group @@ -100,7 +100,7 @@ def test_empty_projects_list(self) -> None: assert result == [] @patch("sentry.seer.fetch_issues.by_function_name.raw_snql_query") - def test_snuba_query_exception(self, mock_query): + def test_snuba_query_exception(self, mock_query: MagicMock) -> None: mock_query.side_effect = Exception("Snuba error") result = _get_issues_for_file( @@ -114,11 +114,13 @@ def test_snuba_query_exception(self, mock_query): def test_basic_matching(self) -> None: # Create events that should match our search criteria - group = self._create_event( + event = self._create_event( function_names=["target_func", "other_func"], filenames=["test.py", "other.py"], user_id="1", - ).group + ) + assert event.group is not None + group = event.group result = _get_issues_for_file( projects=[self.project], @@ -135,11 +137,13 @@ def test_basic_matching(self) -> None: def test_filename_mismatch(self) -> None: # Create event with different filename - group = self._create_event( + event = self._create_event( function_names=["target_func"], filenames=["other.py"], user_id="1", - ).group + ) + assert event.group is not None + group = event.group result = _get_issues_for_file( projects=[self.project], @@ -155,11 +159,13 @@ def test_filename_mismatch(self) -> None: def test_function_name_mismatch(self) -> None: # Create event with different function name - group = self._create_event( + event = self._create_event( function_names=["other_func"], filenames=["test.py"], user_id="1", - ).group + ) + assert event.group is not None + group = event.group result = _get_issues_for_file( projects=[self.project], @@ -175,12 +181,14 @@ def test_function_name_mismatch(self) -> None: def test_event_too_old(self) -> None: # Create old event - use a smaller offset to avoid timestamp validation errors - group = self._create_event( + event = self._create_event( function_names=["target_func"], filenames=["test.py"], timestamp=before_now(days=NUM_DAYS_AGO + 1).isoformat(), user_id="1", - ).group + ) + assert event.group is not None + group = event.group result = _get_issues_for_file( projects=[self.project], @@ -196,11 +204,13 @@ def test_event_too_old(self) -> None: def test_javascript_simple(self) -> None: # Test with JavaScript files to ensure language-agnostic functionality - group = self._create_event( + event = self._create_event( function_names=["component.blue", "world"], filenames=["foo.js", "baz.js"], user_id="1", - ).group + ) + assert event.group is not None + group = event.group result = _get_issues_for_file( projects=[self.project], @@ -222,11 +232,13 @@ def test_stackframe_limit_edge_case(self) -> None: function_names = ["other_func" for _ in range(STACKFRAME_COUNT - 1)] + ["world"] filenames = ["other.py" for _ in range(STACKFRAME_COUNT - 1)] + ["test.py"] - group = self._create_event( + event = self._create_event( function_names=function_names, filenames=filenames, user_id="1", - ).group + ) + assert event.group is not None + group = event.group result = _get_issues_for_file( projects=[self.project], @@ -242,13 +254,17 @@ def test_stackframe_limit_edge_case(self) -> None: def test_multiple_matching_issues(self) -> None: # Create multiple events that should match - group1 = self._create_event( + event = self._create_event( function_names=["target_func"], filenames=["test.py"], user_id="1", culprit="issue1" - ).group + ) + assert event.group is not None + group1 = event.group - group2 = self._create_event( + event = self._create_event( function_names=["target_func"], filenames=["test.py"], user_id="2", culprit="issue2" - ).group + ) + assert event.group is not None + group2 = event.group result = _get_issues_for_file( projects=[self.project], @@ -284,11 +300,13 @@ def setUp(self) -> None: def test_successful_fetch(self) -> None: # Create some test events - group = self._create_event( + event = self._create_event( filenames=["test.py", "other.py"], function_names=["target_function", "other_func"], user_id="1", - ).group + ) + assert event.group is not None + group = event.group with patch( "sentry.seer.fetch_issues.by_function_name._fetch_issues_from_repo_projects" @@ -320,10 +338,12 @@ def test_successful_fetch(self) -> None: mock_serialize.assert_called_once_with([group]) def test_custom_max_num_issues(self) -> None: - group = self._create_event( + event = self._create_event( filenames=["test.py"], function_names=["target_function"], - ).group + ) + assert event.group is not None + group = event.group with patch( "sentry.seer.fetch_issues.by_function_name._fetch_issues_from_repo_projects" @@ -354,7 +374,9 @@ def test_custom_max_num_issues(self) -> None: assert call_args[1]["max_num_issues_per_file"] == 10 def test_with_run_id(self) -> None: - group = self._create_event().group + event = self._create_event() + assert event.group is not None + group = event.group with patch( "sentry.seer.fetch_issues.by_function_name._fetch_issues_from_repo_projects" @@ -450,7 +472,9 @@ def setUp(self) -> None: @patch("sentry.seer.fetch_issues.by_function_name._get_projects_and_filenames_from_source_file") @patch("sentry.seer.fetch_issues.by_function_name._get_issues_for_file") - def test_no_projects_found_fallback(self, mock_get_issues, mock_get_projects): + def test_no_projects_found_fallback( + self, mock_get_issues: MagicMock, mock_get_projects: MagicMock + ) -> None: # Mock no projects found initially mock_get_projects.return_value = (set(), {"test.py"}) mock_get_issues.return_value = [] @@ -478,7 +502,9 @@ def test_no_projects_found_fallback(self, mock_get_issues, mock_get_projects): @patch("sentry.seer.fetch_issues.by_function_name._get_projects_and_filenames_from_source_file") @patch("sentry.seer.fetch_issues.by_function_name._get_issues_for_file") - def test_projects_found_no_fallback(self, mock_get_issues, mock_get_projects): + def test_projects_found_no_fallback( + self, mock_get_issues: MagicMock, mock_get_projects: MagicMock + ) -> None: # Mock projects found mock_get_projects.return_value = ({self.project}, {"test.py"}) mock_get_issues.return_value = [] @@ -507,8 +533,8 @@ def test_projects_found_no_fallback(self, mock_get_issues, mock_get_projects): @patch("sentry.seer.fetch_issues.by_function_name._get_projects_and_filenames_from_source_file") @patch("sentry.seer.fetch_issues.by_function_name._get_issues_for_file") def test_drops_projects_outside_the_authorized_repo_projects( - self, mock_get_issues, mock_get_projects - ): + self, mock_get_issues: MagicMock, mock_get_projects: MagicMock + ) -> None: other_project = self.create_project(organization=self.organization) mock_get_projects.return_value = ({self.project, other_project}, {"test.py"}) mock_get_issues.return_value = [] @@ -540,6 +566,7 @@ def test_fetch_issues_from_repo_projects_returns_groups(self) -> None: function_names=["target_function", "other_func"], user_id="1", ) + assert event.group is not None expected_group = event.group # Get repo projects diff --git a/tests/sentry/seer/fetch_issues/test_by_text_query.py b/tests/sentry/seer/fetch_issues/test_by_text_query.py index 8f4e52451403..0155069d8c1d 100644 --- a/tests/sentry/seer/fetch_issues/test_by_text_query.py +++ b/tests/sentry/seer/fetch_issues/test_by_text_query.py @@ -4,6 +4,7 @@ from sentry.seer.fetch_issues import utils from sentry.seer.fetch_issues.by_text_query import _fetch_issues_from_repo_projects, fetch_issues from sentry.seer.fetch_issues.utils import get_repo_and_projects +from sentry.services.eventstore.models import Event from sentry.testutils.cases import IntegrationTestCase, TestCase from sentry.testutils.helpers.datetime import before_now @@ -11,14 +12,14 @@ class CreateEventTestCase(TestCase): def _create_event( self, - culprit=None, - timestamp=None, - filenames=None, - function_names=None, - project_id=None, - user_id=None, - handled=False, - ): + culprit: str | None = None, + timestamp: str | None = None, + filenames: list[str] | None = None, + function_names: list[str] | None = None, + project_id: int | None = None, + user_id: str | None = None, + handled: bool = False, + ) -> Event: if culprit is None: culprit = "issue0" if timestamp is None: @@ -36,7 +37,7 @@ def _create_event( for i, filename in enumerate(filenames): frames.append({"filename": filename, "function": function_names[i]}) - return self.store_event( + event: Event = self.store_event( data={ "message": "hello!", "culprit": culprit, @@ -57,6 +58,7 @@ def _create_event( }, project_id=project_id, ) + return event class TestFetchIssuesByTextQuery(IntegrationTestCase, CreateEventTestCase): @@ -79,12 +81,14 @@ def setUp(self) -> None: def test_fetch_issues_message_substring_search(self) -> None: """Test that text queries do case-insensitive substring search in issue messages.""" - group = self._create_event( + event = self._create_event( filenames=["auth.py", "utils.py"], function_names=["authenticate", "validate"], culprit="Authentication failed", user_id="1", - ).group + ) + assert event.group is not None + group = event.group # Test queries that should match content in the message # Message will be something like: "hello! Error auth.py authenticate Authentication failed" @@ -159,12 +163,14 @@ def test_fetch_issues_no_match(self) -> None: def test_fetch_issues_culprit_search(self) -> None: """Test that queries match content in the culprit field.""" - group = self._create_event( + event = self._create_event( filenames=["test.py"], function_names=["test_func"], culprit="Database connection timeout", user_id="1", - ).group + ) + assert event.group is not None + group = event.group # Query for a keyword from the culprit assert self.gh_repo.external_id is not None @@ -216,6 +222,7 @@ def test_fetch_issues_from_repo_projects_returns_groups(self) -> None: culprit="Authentication failed", user_id="1", ) + assert event.group is not None expected_group = event.group # Get repo projects diff --git a/tests/sentry/toolbar/utils/test_url.py b/tests/sentry/toolbar/utils/test_url.py index 20d4ebaa4435..99b4bedb3652 100644 --- a/tests/sentry/toolbar/utils/test_url.py +++ b/tests/sentry/toolbar/utils/test_url.py @@ -26,7 +26,7 @@ def test_url_matches_scheme(referrer: str, target: str) -> None: ("https://example.com", "http://example.com"), ], ) -def test_url_matches_rejects_mispatched_scheme(referrer, target) -> None: +def test_url_matches_rejects_mispatched_scheme(referrer: str, target: str) -> None: assert not url_matches(urlparse(referrer), target) diff --git a/tests/sentry/toolbar/views/test_iframe_view.py b/tests/sentry/toolbar/views/test_iframe_view.py index 7a48b52a186f..459d6ea6e7d9 100644 --- a/tests/sentry/toolbar/views/test_iframe_view.py +++ b/tests/sentry/toolbar/views/test_iframe_view.py @@ -1,3 +1,4 @@ +from django.http import HttpResponse from django.test import override_settings from django.urls import reverse from django.utils.html import escapejs @@ -103,13 +104,14 @@ def test_no_referrer(self) -> None: assert f"const state = '{escapejs('invalid-domain')}';" in res.content.decode("utf-8") -def _get_csp_parts(response): +def _get_csp_parts(response: HttpResponse) -> list[str]: # Fallback to `c` for tests in dev. csp = response.headers.get("Content-Security-Policy", response.headers.get("c")) + assert csp is not None return [chunk.strip() for chunk in csp.split(";")] -def _has_nonce(response): +def _has_nonce(response: HttpResponse) -> bool: content = response.content.decode("utf-8") # Middleware automatically injects the `nonce` attribute onto our