From 0cafdc5a6f27c95ed5dc126e14640b27f7dad809 Mon Sep 17 00:00:00 2001 From: Joshua Li Date: Sat, 31 Oct 2026 09:49:09 -0700 Subject: [PATCH] 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}."